From a0f5bd020381c0c19455abacdc975e9ae3c215e3 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 7 Apr 2026 00:08:43 +1000
Subject: [PATCH 01/53] Extra icons inital commit.
---
.luacheckrc | 6 +-
ARCHITECTURE.md | 53 +-
BarMixin.lua | 2 +-
Constants.lua | 63 +-
Defaults.lua | 29 +-
EnhancedCooldownManager.toc | 4 +-
Locales/en.lua | 35 +-
Migration.lua | 64 ++
Modules/ExtraIcons.lua | 688 ++++++++++++++++++
Modules/ItemIcons.lua | 490 -------------
Tests/Migration_spec.lua | 185 ++++-
Tests/Modules/ExtraIcons_spec.lua | 1007 +++++++++++++++++++++++++++
Tests/Modules/ItemIcons_spec.lua | 787 ---------------------
Tests/UI/ExtraIconsOptions_spec.lua | 448 ++++++++++++
Tests/UI/ItemIconsOptions_spec.lua | 101 ---
Tests/UI/OptionsSections_spec.lua | 4 +-
UI/ExtraIconsOptions.lua | 650 +++++++++++++++++
UI/ItemIconsOptions.lua | 76 --
UI/Options.lua | 2 +-
19 files changed, 3163 insertions(+), 1531 deletions(-)
create mode 100644 Modules/ExtraIcons.lua
delete mode 100644 Modules/ItemIcons.lua
create mode 100644 Tests/Modules/ExtraIcons_spec.lua
delete mode 100644 Tests/Modules/ItemIcons_spec.lua
create mode 100644 Tests/UI/ExtraIconsOptions_spec.lua
delete mode 100644 Tests/UI/ItemIconsOptions_spec.lua
create mode 100644 UI/ExtraIconsOptions.lua
delete mode 100644 UI/ItemIconsOptions.lua
diff --git a/.luacheckrc b/.luacheckrc
index 1bbfa1f3..8e00e16a 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -28,7 +28,7 @@ globals = {
"UISpecialFrames"
}
-read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', 'WorldFrame', 'GameTooltip_OnLoad', 'GetScreenWidth', 'GetScreenHeight', 'HideUIPanel', 'ChatFontNormal', 'GameFontNormalSmall', 'GameFontHighlightSmall', 'EnumUtil', 'TooltipDataProcessor', 'C_EventUtils', 'ItemRefTooltip', 'ShowUIPanel', 'GetPlayerInfoByGUID', 'C_FriendList', 'NUM_CHAT_WINDOWS', 'COMBATLOG', 'WHO_LIST_FORMAT', 'WHO_LIST_GUILD_FORMAT', 'ERR_FRIEND_ONLINE_SS', 'GetNumGroupMembers', 'IsInRaid', 'C_RestrictedActions',
+read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip', 'GameTooltip_Hide', 'WorldFrame', 'GameTooltip_OnLoad', 'GetScreenWidth', 'GetScreenHeight', 'HideUIPanel', 'ChatFontNormal', 'GameFontNormalSmall', 'GameFontHighlightSmall', 'EnumUtil', 'TooltipDataProcessor', 'C_EventUtils', 'ItemRefTooltip', 'ShowUIPanel', 'GetPlayerInfoByGUID', 'C_FriendList', 'NUM_CHAT_WINDOWS', 'COMBATLOG', 'WHO_LIST_FORMAT', 'WHO_LIST_GUILD_FORMAT', 'ERR_FRIEND_ONLINE_SS', 'GetNumGroupMembers', 'IsInRaid', 'C_RestrictedActions',
"bit",
"ceil", "floor",
"mod",
@@ -59,7 +59,7 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip',
"GetInventoryItemCooldown", "GetInventoryItemID", "GetInventoryItemTexture", "GetRuneCooldown",
"GetShapeshiftForm", "GetSpecialization", "GetSpecializationInfo", "GetSpecializationRole",
"hooksecurefunc",
- "InCombatLockdown", "IsControlKeyDown", "IsInInstance", "IsMounted", "IsResting",
+ "InCombatLockdown", "IsControlKeyDown", "IsInInstance", "IsMounted", "IsPlayerSpell", "IsResting",
"issecrettable", "issecretvalue",
"LibStub",
"NO",
@@ -74,7 +74,7 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip',
"SETTINGS_DEFAULTS",
"StaticPopup_Show",
"UIParent",
- "UnitCanAssist", "UnitCanAttack", "UnitClass", "UnitExists", "UnitIsPlayer", "UnitName", "UnitInVehicle", "UnitOnTaxi", "UnitIsDead", "UnitName",
+ "UnitCanAssist", "UnitCanAttack", "UnitClass", "UnitExists", "UnitIsPlayer", "UnitName", "UnitInVehicle", "UnitOnTaxi", "UnitIsDead", "UnitName", "UnitRace",
"UnitPower", "UnitPowerMax", "UnitPowerPercent", "UnitPowerType",
"YES",
"MinimalSliderWithSteppersMixin",
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 4d5fa523..44795def 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,7 +1,7 @@
# ECM Architecture
EnhancedCooldownManager is an event-driven WoW addon built on AceAddon-3.0 / AceDB-3.0.
-`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, and iterates modules. Each module (PowerBar, ResourceBar, RuneBar, BuffBars, ItemIcons) inherits from `BarMixin` and implements its own `UpdateLayout()`.
+`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, and iterates modules. Each module (PowerBar, ResourceBar, RuneBar, BuffBars, ExtraIcons) inherits from `BarMixin` and implements its own `UpdateLayout()`.
## Initialization Chain
@@ -27,7 +27,7 @@ flowchart TD
RB_INIT["ResourceBar:OnInitialize()
BarMixin.AddBarMixin(self)"]
RUNE_INIT["RuneBar:OnInitialize()
BarMixin.AddBarMixin(self)"]
BB_INIT["BuffBars:OnInitialize()
BarMixin.AddFrameMixin(self)"]
- II_INIT["ItemIcons:OnInitialize()"]
+ II_INIT["ExtraIcons:OnInitialize()"]
end
subgraph ENABLE["Phase 4 · OnEnable → Runtime.Enable"]
@@ -143,7 +143,7 @@ flowchart TD
subgraph UPDATE_ALL["updateAllLayouts(reason)"]
INV_DET["invalidateDetachedAnchorMetrics()"]
UPD_DET["updateDetachedAnchorLayout()"]
- CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ItemIcons"]
+ CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ExtraIcons"]
MOD_UPD["module:UpdateLayout(reason)"]
INV_DET --> UPD_DET --> CHAIN_LOOP --> MOD_UPD
@@ -267,7 +267,7 @@ Runtime registers the shared layout events; modules register their own data-driv
| Event | Registrant(s) | Purpose |
|-------|---------------|---------|
| CVAR_UPDATE | Runtime | Schedules layout when `cooldownViewerEnabled` changes |
-| PLAYER_ENTERING_WORLD | Runtime, BuffBars, ItemIcons | Runtime: full layout; BuffBars: refresh zone buffs; ItemIcons: full refresh |
+| PLAYER_ENTERING_WORLD | Runtime, BuffBars, ExtraIcons | Runtime: full layout; BuffBars: refresh zone buffs; ExtraIcons: full refresh |
| PLAYER_MOUNT_DISPLAY_CHANGED | Runtime | Immediate layout for mounted-state visibility |
| PLAYER_REGEN_DISABLED | Runtime | Immediate layout; sets `_inCombat` flag |
| PLAYER_REGEN_ENABLED | Runtime | Delayed layout (combat-end delay); clears `_inCombat` |
@@ -281,9 +281,11 @@ Runtime registers the shared layout events; modules register their own data-driv
| ZONE_CHANGED | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh zone-specific buffs |
| ZONE_CHANGED_INDOORS | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh buff data |
| ZONE_CHANGED_NEW_AREA | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh area-specific buffs |
-| BAG_UPDATE_COOLDOWN | ItemIcons | Throttled cooldown-state refresh |
-| BAG_UPDATE_DELAYED | ItemIcons | Layout update after bag contents finalize |
-| PLAYER_EQUIPMENT_CHANGED | ItemIcons | Refresh equipped trinket cooldowns on gear swap |
+| BAG_UPDATE_COOLDOWN | ExtraIcons | Throttled cooldown-state refresh |
+| BAG_UPDATE_DELAYED | ExtraIcons | Layout update after bag contents finalize |
+| PLAYER_EQUIPMENT_CHANGED | ExtraIcons | Refresh tracked equipment slot cooldowns on gear swap |
+| SPELLS_CHANGED | ExtraIcons | Layout update when known spells change (talent/level) |
+| SPELL_UPDATE_COOLDOWN | ExtraIcons | Throttled spell cooldown-state refresh |
| RUNE_POWER_UPDATE | RuneBar | Start rune animation ticker; request layout |
| UNIT_AURA | ResourceBar | Layout update when player auras change |
| UNIT_POWER_UPDATE | PowerBar, ResourceBar | PowerBar: primary power bar update; ResourceBar: resource tracking |
@@ -315,7 +317,7 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
| Method | Description |
|--------|-------------|
-| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ItemIcons) |
+| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ExtraIcons) |
| `AddBarMixin(module, name)` | Apply bar mixin: frame + StatusBar + ticks (used by PowerBar, ResourceBar, RuneBar) |
**FrameProto (mixed into every module):**
@@ -347,6 +349,41 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
| `LayoutResourceTicks(maxResources, color?, tickWidth?, poolKey?)` | Position ticks as resource dividers |
| `LayoutValueTicks(statusBar, ticks, maxValue, defaultColor, defaultWidth, poolKey?)` | Position ticks at specific values |
+### ExtraIcons (`Modules/ExtraIcons.lua`)
+
+Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Uses a dual-viewer architecture with a stack-aware resolver.
+
+**Viewer Registry:** Maps abstract viewer keys to Blizzard frame globals. Current keys: `"utility"` → `UtilityCooldownViewer`, `"main"` → `EssentialCooldownViewer`. Each viewer has its own container frame, on-demand icon pool, centering offset, and hook set.
+
+**Entry Kinds and Resolution:**
+
+| Kind | Config Fields | Resolution | Cooldown Source |
+|------|--------------|------------|-----------------|
+| `equipSlot` | `slotId` | `GetInventoryItemID` + `C_Item.GetItemSpell` on-use check | `GetInventoryItemCooldown` |
+| `item` | `ids[]` (priority stack) | First with `C_Item.GetItemCount > 0` | `C_Item.GetItemCooldown` |
+| `spell` | `ids[]` (priority stack) | First with `IsPlayerSpell` → `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
+
+Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Custom and racial entries store fields directly in saved config.
+
+**Config Structure (`profile.extraIcons`):**
+
+```lua
+{
+ enabled = true,
+ viewers = {
+ utility = { -- ordered array
+ { stackKey = "trinket1" }, -- resolved from BUILTIN_STACKS
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { kind = "spell", ids = { 59752 } }, -- racial (self-contained)
+ },
+ main = {},
+ },
+}
+```
+
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and embeds a canvas frame for viewer management. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Add buttons for absent predefined stacks and racials; per-row controls for reorder (↑↓), move between viewers (→←), and delete (✕); custom entry form with ID parsing.
+
### FrameUtil (`ns.FrameUtil`)
Lazy setters avoid redundant frame API calls — they compare the new value against state and only call the Blizzard API when it changed.
diff --git a/BarMixin.lua b/BarMixin.lua
index 2a90c0ae..2e48618d 100644
--- a/BarMixin.lua
+++ b/BarMixin.lua
@@ -788,7 +788,7 @@ function BarMixin.AssertValid(target)
end
--- Applies frame-only mixin (positioning, visibility, edit mode, config access).
---- Used by modules that manage their own inner content (e.g. BuffBars, ItemIcons).
+--- Used by modules that manage their own inner content (e.g. BuffBars, ExtraIcons).
--- Idempotent — safe to call more than once (no-op after first application).
--- @param target table table to apply the mixin to.
--- @param name string the module name. must be unique.
diff --git a/Constants.lua b/Constants.lua
index 36aa7091..ec2ae8de 100644
--- a/Constants.lua
+++ b/Constants.lua
@@ -14,7 +14,7 @@ local constants = {
-- Module identifiers
BUFFBARS = "BuffBars",
- ITEMICONS = "ItemIcons",
+ EXTRAICONS = "ExtraIcons",
POWERBAR = "PowerBar",
RESOURCEBAR = "ResourceBar",
RUNEBAR = "RuneBar",
@@ -100,10 +100,9 @@ local constants = {
SHAMAN_ENHANCEMENT_SPEC_INDEX = 2,
SHAMAN_RESTORATION_SPEC_INDEX = 3,
- -- Item icons
- DEFAULT_ITEM_ICON_SIZE = 32,
- ITEM_ICON_BORDER_SCALE = 1.35,
- ITEM_ICONS_MAX = 5,
+ -- Extra icons
+ DEFAULT_EXTRA_ICON_SIZE = 32,
+ EXTRA_ICON_BORDER_SCALE = 1.35,
-- Consumables and equipment slots
COMBAT_POTIONS = {
@@ -123,12 +122,10 @@ local constants = {
{ itemID = 224464 }, -- Demonic Healthstone
{ itemID = 5512 }, -- Healthstone
},
- TRINKET_SLOT_1 = 13,
- TRINKET_SLOT_2 = 14,
-- Saved variables and migration
ACTIVE_SV_KEY = "_ECM_DB",
- CURRENT_SCHEMA_VERSION = 11,
+ CURRENT_SCHEMA_VERSION = 12,
SV_NAME = "EnhancedCooldownManagerDB",
-- Import and export
@@ -194,6 +191,49 @@ local constants = {
},
}
+--- Predefined icon stacks resolved at runtime by stackKey.
+--- Each entry defines an icon kind and its candidate sources.
+local BUILTIN_STACKS = {
+ trinket1 = { kind = "equipSlot", slotId = 13, label = "Trinket 1" },
+ trinket2 = { kind = "equipSlot", slotId = 14, label = "Trinket 2" },
+ combatPotions = { kind = "item", ids = constants.COMBAT_POTIONS, label = "Combat Potions" },
+ healthPotions = { kind = "item", ids = constants.HEALTH_POTIONS, label = "Health Potions" },
+ healthstones = { kind = "item", ids = constants.HEALTHSTONES, label = "Healthstones" },
+}
+
+--- Default display order for builtin stack keys (matches default viewers.utility order).
+local BUILTIN_STACK_ORDER = { "trinket1", "trinket2", "combatPotions", "healthPotions", "healthstones" }
+
+--- Racial ability lookup keyed by UnitRace("player") raceFileName.
+--- One primary active racial per race.
+local RACIAL_ABILITIES = {
+ Human = { spellId = 59752 }, -- Every Man for Himself
+ Orc = { spellId = 33697 }, -- Blood Fury
+ Dwarf = { spellId = 20594 }, -- Stoneform
+ NightElf = { spellId = 58984 }, -- Shadowmeld
+ Scourge = { spellId = 7744 }, -- Will of the Forsaken
+ Tauren = { spellId = 20549 }, -- War Stomp
+ Gnome = { spellId = 20589 }, -- Escape Artist
+ Troll = { spellId = 26297 }, -- Berserking
+ Goblin = { spellId = 69070 }, -- Rocket Barrage
+ BloodElf = { spellId = 28730 }, -- Arcane Torrent
+ Draenei = { spellId = 59542 }, -- Gift of the Naaru
+ Worgen = { spellId = 68992 }, -- Darkflight
+ Pandaren = { spellId = 107079 }, -- Quaking Palm
+ Nightborne = { spellId = 260364 }, -- Arcane Pulse
+ HighmountainTauren = { spellId = 255654 }, -- Bull Rush
+ VoidElf = { spellId = 256948 }, -- Spatial Rift
+ LightforgedDraenei = { spellId = 255647 }, -- Light's Judgment
+ ZandalariTroll = { spellId = 291944 }, -- Regeneratin'
+ KulTiran = { spellId = 287712 }, -- Haymaker
+ DarkIronDwarf = { spellId = 265221 }, -- Fireblood
+ Vulpera = { spellId = 312411 }, -- Bag of Tricks
+ MagharOrc = { spellId = 274738 }, -- Ancestral Call
+ Mechagnome = { spellId = 312924 }, -- Hyper Organic Light Originator
+ Dracthyr = { spellId = 368970 }, -- Tail Swipe
+ EarthenDwarf = { spellId = 436717 }, -- Azerite Surge
+}
+
local BLIZZARD_FRAMES = {
"EssentialCooldownViewer",
"UtilityCooldownViewer",
@@ -236,7 +276,7 @@ local moduleConfigKeys = {
[constants.RESOURCEBAR] = "resourceBar",
[constants.RUNEBAR] = "runeBar",
[constants.BUFFBARS] = "buffBars",
- [constants.ITEMICONS] = "itemIcons",
+ [constants.EXTRAICONS] = "extraIcons",
}
--- Returns the profile config key for a module name.
@@ -247,9 +287,12 @@ end
local chainOrder = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS }
constants.CHAIN_ORDER = chainOrder
-constants.MODULE_ORDER = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS, constants.ITEMICONS }
+constants.MODULE_ORDER = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS, constants.EXTRAICONS }
constants.MODULE_CONFIG_KEYS = moduleConfigKeys
constants.BLIZZARD_FRAMES = BLIZZARD_FRAMES
+constants.BUILTIN_STACKS = BUILTIN_STACKS
+constants.BUILTIN_STACK_ORDER = BUILTIN_STACK_ORDER
+constants.RACIAL_ABILITIES = RACIAL_ABILITIES
constants.RESOURCEBAR_CASTABLE_MAX_COLOR_SPELLS = resourceBarCastableMaxColorSpells
constants.CLASS_COLORS = CLASS_COLORS
constants.RESOURCEBAR_MAX_COLOR_TYPES = resourceBarMaxColorTypes
diff --git a/Defaults.lua b/Defaults.lua
index 19549d70..580c728d 100644
--- a/Defaults.lua
+++ b/Defaults.lua
@@ -99,13 +99,9 @@ local _, ns = ...
---@field fontSize number|nil Font size override for aura bar text.
---@field colors ECM_SpellColorsConfig Per-spell color settings.
----@class ECM_ItemIconsConfig Item icons configuration.
----@field enabled boolean Whether item icons are enabled.
----@field showTrinket1 boolean Whether to show trinket slot 1 (if on-use).
----@field showTrinket2 boolean Whether to show trinket slot 2 (if on-use).
----@field showCombatPotion boolean Whether to show combat potions.
----@field showHealthPotion boolean Whether to show health potions.
----@field showHealthstone boolean Whether to show healthstone.
+---@class ECM_ExtraIconsConfig Extra icons configuration.
+---@field enabled boolean Whether extra icons are enabled.
+---@field viewers table Per-viewer ordered icon lists.
---@class ECM_TickMark Tick mark definition.
---@field value number Tick mark value.
@@ -131,7 +127,7 @@ local _, ns = ...
---@field resourceBar ECM_ResourceBarConfig Resource bar settings.
---@field runeBar ECM_RuneBarConfig Rune bar settings.
---@field buffBars ECM_BuffBarsConfig Buff bars configuration.
----@field itemIcons ECM_ItemIconsConfig Item icons configuration.
+---@field extraIcons ECM_ExtraIconsConfig Extra icons configuration.
local C = ns.Constants
@@ -273,13 +269,18 @@ local defaults = {
defaultColor = { r = 228 / 255, g = 233 / 255, b = 235 / 255, a = 1 },
},
},
- itemIcons = {
+ extraIcons = {
enabled = true,
- showTrinket1 = true,
- showTrinket2 = true,
- showCombatPotion = true,
- showHealthPotion = true,
- showHealthstone = true,
+ viewers = {
+ utility = {
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ },
+ main = {},
+ },
},
},
}
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 8173c72b..af227a53 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -44,7 +44,7 @@ Modules\BuffBars.lua
Modules\PowerBar.lua
Modules\ResourceBar.lua
Modules\RuneBar.lua
-Modules\ItemIcons.lua
+Modules\ExtraIcons.lua
UI\OptionUtil.lua
UI\Options.lua
@@ -55,5 +55,5 @@ UI\PowerBarOptions.lua
UI\ResourceBarOptions.lua
UI\RuneBarOptions.lua
UI\ProfileOptions.lua
-UI\ItemIconsOptions.lua
+UI\ExtraIconsOptions.lua
UI\BuffBarsOptions.lua
diff --git a/Locales/en.lua b/Locales/en.lua
index 507d0e5c..ff1ae88c 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -70,7 +70,7 @@ L["POWER_BAR"] = "Power Bar"
L["RESOURCE_BAR"] = "Resource Bar"
L["RUNE_BAR"] = "Rune Bar"
L["AURA_BARS"] = "Aura Bars"
-L["ITEM_ICONS"] = "Item Icons"
+L["EXTRA_ICONS"] = "Extra Icons"
--------------------------------------------------------------------------------
-- Module Shared
@@ -219,20 +219,25 @@ L["NO_TICK_MARKS"] = "%s - no tick marks configured."
L["TICK_COUNT"] = "%s - %d tick mark(s) configured."
--------------------------------------------------------------------------------
--- Item Icons Options
---------------------------------------------------------------------------------
-
-L["ENABLE_ITEM_ICONS"] = "Enable item icons"
-L["ENABLE_ITEM_ICONS_DESC"] =
- "Display icons for equipped on-use trinkets and select consumables to the right of utility cooldowns."
-L["EQUIPMENT"] = "Equipment"
-L["CONSUMABLES"] = "Consumables"
-L["SHOW_FIRST_TRINKET"] = "Show first trinket"
-L["SHOW_FIRST_TRINKET_DESC"] = "Display icons for usable equipment. Trinkets without an on-use effect are never shown."
-L["SHOW_SECOND_TRINKET"] = "Show second trinket"
-L["SHOW_HEALTH_POTIONS"] = "Show health potions"
-L["SHOW_COMBAT_POTIONS"] = "Show combat potions"
-L["SHOW_HEALTHSTONE"] = "Show healthstone"
+-- Extra Icons Options
+--------------------------------------------------------------------------------
+
+L["ENABLE_EXTRA_ICONS"] = "Enable extra icons"
+L["ENABLE_EXTRA_ICONS_DESC"] =
+ "Display icons for equipped on-use trinkets, select consumables, and custom spells or items to the right of cooldown viewers."
+L["UTILITY_VIEWER_ICONS"] = "Utility Viewer Icons"
+L["MAIN_VIEWER_ICONS"] = "Main Viewer Icons"
+L["ADD_RACIAL"] = "Add %s"
+L["ADD_ITEM"] = "Add Item"
+L["ADD_SPELL"] = "Add Spell"
+L["EXTRA_ICONS_RESET_CONFIRM"] = "Reset extra icons to defaults?"
+L["ADD_CUSTOM_IDS"] = "Spell or Item IDs (comma-separated)"
+L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
+L["REMOVE_ENTRY_CONFIRM"] = "Remove %s?"
+L["MOVE_UP_TOOLTIP"] = "Move up"
+L["MOVE_DOWN_TOOLTIP"] = "Move down"
+L["MOVE_TO_VIEWER_TOOLTIP"] = "Move to %s viewer"
+L["REMOVE_TOOLTIP"] = "Remove"
--------------------------------------------------------------------------------
-- About
diff --git a/Migration.lua b/Migration.lua
index b0f55b50..48ea1a8a 100644
--- a/Migration.lua
+++ b/Migration.lua
@@ -1154,6 +1154,70 @@ _migrations[11] = function(profile)
end
end
+-- V11 → V12: convert itemIcons boolean flags to extraIcons.viewers structure.
+-- Old format: itemIcons = { enabled, showTrinket1, showTrinket2, showCombatPotion, showHealthPotion, showHealthstone }
+-- New format: extraIcons = { enabled, viewers = { utility = { {stackKey=...}, ... }, main = {} } }
+-- Frozen snapshot: flag names and stackKey values are inlined constants.
+_migrations[12] = function(profile)
+ local old = profile.itemIcons
+ if type(old) ~= "table" then
+ log("V12 no itemIcons section found; seeding default extraIcons")
+ profile.extraIcons = profile.extraIcons or {
+ enabled = true,
+ viewers = {
+ utility = {
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ },
+ main = {},
+ },
+ }
+ profile.itemIcons = nil
+ return
+ end
+
+ local enabled = old.enabled
+ if enabled == nil then
+ enabled = true
+ end
+
+ -- Map old boolean flags to stackKey entries in display order
+ local FLAG_TO_STACK = {
+ { flag = "showTrinket1", stackKey = "trinket1" },
+ { flag = "showTrinket2", stackKey = "trinket2" },
+ { flag = "showCombatPotion", stackKey = "combatPotions" },
+ { flag = "showHealthPotion", stackKey = "healthPotions" },
+ { flag = "showHealthstone", stackKey = "healthstones" },
+ }
+
+ local utilityEntries = {}
+ for _, mapping in ipairs(FLAG_TO_STACK) do
+ local flagValue = old[mapping.flag]
+ if flagValue == nil or flagValue == true then
+ utilityEntries[#utilityEntries + 1] = { stackKey = mapping.stackKey }
+ end
+ end
+
+ profile.extraIcons = {
+ enabled = enabled,
+ viewers = {
+ utility = utilityEntries,
+ main = {},
+ },
+ }
+
+ local migrated = {}
+ for _, entry in ipairs(utilityEntries) do
+ migrated[#migrated + 1] = entry.stackKey
+ end
+ log("V12 migrated itemIcons -> extraIcons.viewers.utility: [" .. table.concat(migrated, ", ") .. "]")
+
+ profile.itemIcons = nil
+end
+
--- Runs all schema migrations on a profile from its current version to CURRENT_SCHEMA_VERSION.
---@param profile table The profile to migrate
function Migration.Run(profile)
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
new file mode 100644
index 00000000..75ff3132
--- /dev/null
+++ b/Modules/ExtraIcons.lua
@@ -0,0 +1,688 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local BarMixin = ns.BarMixin
+local ExtraIcons = ns.Addon:NewModule("ExtraIcons")
+ns.Addon.ExtraIcons = ExtraIcons
+
+---@class ECM_ExtraIconsModule : ECM_FrameProto
+
+---@class ECM_IconData
+---@field itemId number|nil Item ID.
+---@field spellId number|nil Spell ID (for spell-kind entries).
+---@field texture string|number Icon texture.
+---@field slotId number|nil Inventory slot ID (equipSlot only).
+
+---@class ECM_ExtraIcon : Button
+---@field slotId number|nil Inventory slot ID this icon represents (equipSlot only).
+---@field itemId number|nil Item ID this icon represents (bag items only).
+---@field spellId number|nil Spell ID this icon represents (spell entries only).
+---@field Icon Texture The icon texture.
+---@field Cooldown Cooldown The cooldown overlay frame.
+
+local BUILTIN_STACKS = ns.Constants.BUILTIN_STACKS
+
+--- Viewer registry mapping viewer keys to their Blizzard frame globals.
+local VIEWER_REGISTRY = {
+ utility = { blizzFrameKey = "UtilityCooldownViewer" },
+ main = { blizzFrameKey = "EssentialCooldownViewer" },
+}
+
+--------------------------------------------------------------------------------
+-- Resolver Functions
+--------------------------------------------------------------------------------
+
+--- Checks if an equipment slot has an on-use effect.
+---@param slotId number Inventory slot ID.
+---@return ECM_IconData|nil iconData Icon data if on-use, nil otherwise.
+local function getEquipSlotData(slotId)
+ local itemId = GetInventoryItemID("player", slotId)
+ if not itemId then
+ return nil
+ end
+
+ local _, spellId = C_Item.GetItemSpell(itemId)
+ if not spellId then
+ return nil
+ end
+
+ local texture = GetInventoryItemTexture("player", slotId)
+ if not texture then
+ return nil
+ end
+
+ return {
+ itemId = itemId,
+ texture = texture,
+ slotId = slotId,
+ }
+end
+
+--- Returns the first item from a priority list that exists in the player's bags.
+---@param ids { itemID: number, quality: number|nil }[] Array of priority entries.
+---@return ECM_IconData|nil iconData Icon data if found, nil otherwise.
+local function getBestConsumable(ids)
+ for _, entry in ipairs(ids) do
+ local itemId = entry.itemID or entry.itemId
+ if itemId and C_Item.GetItemCount(itemId) > 0 then
+ local texture = C_Item.GetItemIconByID(itemId)
+ if texture then
+ return {
+ itemId = itemId,
+ texture = texture,
+ }
+ end
+ end
+ end
+ return nil
+end
+
+--- Returns the first known spell from an ID list.
+---@param ids { spellId: number }[]|number[] Array of spell entries or raw IDs.
+---@return ECM_IconData|nil iconData Icon data if found, nil otherwise.
+local function getSpellData(ids)
+ for _, entry in ipairs(ids) do
+ local spellId = type(entry) == "table" and entry.spellId or entry
+ if IsPlayerSpell(spellId) then
+ local texture = C_Spell.GetSpellTexture(spellId)
+ if texture then
+ return {
+ spellId = spellId,
+ texture = texture,
+ }
+ end
+ end
+ end
+ return nil
+end
+
+--- Resolves a single config entry to displayable icon data.
+---@param entry table A config entry with stackKey or kind+ids/slotId.
+---@return ECM_IconData|nil iconData Resolved icon data, or nil if unavailable.
+local function resolveEntry(entry)
+ local kind, slotId, ids
+
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then
+ return nil
+ end
+ kind = stack.kind
+ slotId = stack.slotId
+ ids = stack.ids
+ else
+ kind = entry.kind
+ slotId = entry.slotId
+ ids = entry.ids
+ end
+
+ if kind == "equipSlot" then
+ return getEquipSlotData(slotId)
+ elseif kind == "item" then
+ return ids and getBestConsumable(ids)
+ elseif kind == "spell" then
+ return ids and getSpellData(ids)
+ end
+ return nil
+end
+
+--- Resolves all entries for a viewer into an ordered array of icon data.
+---@param entries table[] Config entries for one viewer.
+---@return ECM_IconData[] items Array of resolved icon data (skipping nil results).
+local _resolvedItems = {}
+
+local function resolveViewerEntries(entries)
+ wipe(_resolvedItems)
+ for _, entry in ipairs(entries) do
+ local data = resolveEntry(entry)
+ if data then
+ _resolvedItems[#_resolvedItems + 1] = data
+ end
+ end
+ return _resolvedItems
+end
+
+--------------------------------------------------------------------------------
+-- Icon Creation and Cooldown
+--------------------------------------------------------------------------------
+
+--- Creates a single extra icon frame styled like cooldown viewer icons.
+---@param parent Frame Parent frame to attach to.
+---@param size number Icon size in pixels.
+---@return ECM_ExtraIcon icon The created icon frame.
+local function createExtraIcon(parent, size)
+ local icon = CreateFrame("Button", nil, parent)
+ icon:SetSize(size, size)
+
+ icon.Icon = icon:CreateTexture(nil, "ARTWORK")
+ icon.Icon:SetPoint("CENTER")
+ icon.Icon:SetSize(size, size)
+
+ icon.Mask = icon:CreateMaskTexture()
+ icon.Mask:SetAtlas("UI-HUD-CoolDownManager-Mask")
+ icon.Mask:SetPoint("CENTER")
+ icon.Mask:SetSize(size, size)
+ icon.Icon:AddMaskTexture(icon.Mask)
+
+ icon.Cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
+ icon.Cooldown:SetAllPoints()
+ icon.Cooldown:SetDrawEdge(true)
+ icon.Cooldown:SetDrawSwipe(true)
+ icon.Cooldown:SetHideCountdownNumbers(false)
+ icon.Cooldown:SetSwipeTexture([[Interface\HUD\UI-HUD-CoolDownManager-Icon-Swipe]], 0, 0, 0, 0.2)
+ icon.Cooldown:SetEdgeTexture([[Interface\Cooldown\UI-HUD-ActionBar-SecondaryCooldown]])
+
+ icon.Border = icon:CreateTexture(nil, "OVERLAY")
+ icon.Border:SetAtlas("UI-HUD-CoolDownManager-IconOverlay")
+ icon.Border:SetPoint("CENTER")
+ icon.Border:SetSize(size * ns.Constants.EXTRA_ICON_BORDER_SCALE, size * ns.Constants.EXTRA_ICON_BORDER_SCALE)
+
+ icon.Shadow = icon:CreateTexture(nil, "OVERLAY")
+ icon.Shadow:SetAtlas("UI-CooldownManager-OORshadow")
+ icon.Shadow:SetAllPoints()
+ icon.Shadow:Hide()
+
+ return icon
+end
+
+--- Updates the cooldown display on an extra icon.
+---@param icon ECM_ExtraIcon The icon to update.
+local function updateIconCooldown(icon)
+ if icon.spellId then
+ local cdInfo = C_Spell.GetSpellCooldown(icon.spellId)
+ if cdInfo and cdInfo.isOnGCD then
+ icon.Cooldown:SetCooldown(0, 0)
+ return
+ end
+ -- Charge spells: show per-charge timer so the icon appears ready while
+ -- charges remain. maxCharges must be >1; single-charge spells report
+ -- zero-span from GetSpellChargeDuration.
+ local chargesInfo = C_Spell.GetSpellCharges(icon.spellId)
+ local isCharge = chargesInfo and chargesInfo.maxCharges and chargesInfo.maxCharges > 1
+ local durObj = isCharge
+ and C_Spell.GetSpellChargeDuration(icon.spellId)
+ or C_Spell.GetSpellCooldownDuration(icon.spellId)
+ if durObj then
+ icon.Cooldown:SetCooldown(0, 0)
+ icon.Cooldown:SetCooldownFromDurationObject(durObj)
+ else
+ icon.Cooldown:Clear()
+ end
+ elseif icon.slotId then
+ local start, duration, enable = GetInventoryItemCooldown("player", icon.slotId)
+ if enable == 1 and duration > 0 then
+ icon.Cooldown:SetCooldown(start, duration)
+ else
+ icon.Cooldown:Clear()
+ end
+ elseif icon.itemId then
+ local start, duration, enable = C_Item.GetItemCooldown(icon.itemId)
+ if enable and duration > 0 then
+ icon.Cooldown:SetCooldown(start, duration)
+ else
+ icon.Cooldown:Clear()
+ end
+ end
+end
+
+--- Gets cooldown number font info from a Blizzard cooldown viewer icon.
+--- Caches the result on the viewer to avoid per-layout child scans.
+---@param viewer Frame
+---@return string|nil fontPath, number|nil fontSize, string|nil fontFlags
+local function getSiblingCooldownNumberFont(viewer)
+ local cached = viewer.__ecmCDFont
+ if cached then
+ return cached[1], cached[2], cached[3]
+ end
+
+ for _, child in ipairs({ viewer:GetChildren() }) do
+ local cooldown = child.Cooldown
+ if cooldown and cooldown.GetRegions then
+ local region = cooldown:GetRegions()
+ if region and region.IsObjectType and region:IsObjectType("FontString") and region.GetFont then
+ local fontPath, fontSize, fontFlags = region:GetFont()
+ if fontPath and fontSize then
+ viewer.__ecmCDFont = { fontPath, fontSize, fontFlags }
+ return fontPath, fontSize, fontFlags
+ end
+ end
+ end
+ end
+end
+
+--- Applies cooldown number font settings to one icon.
+---@param icon ECM_ExtraIcon
+---@param fontPath string
+---@param fontSize number
+---@param fontFlags string|nil
+local function applyCooldownNumberFont(icon, fontPath, fontSize, fontFlags)
+ local region = icon.Cooldown:GetRegions()
+ if region and region.IsObjectType and region:IsObjectType("FontString") and region.SetFont then
+ region:SetFont(fontPath, fontSize, fontFlags)
+ end
+end
+
+--- Ensures the viewer's icon pool has at least `needed` icons.
+---@param viewerState table Per-viewer state with .container and .iconPool.
+---@param needed number Required pool size.
+local function ensurePoolSize(viewerState, needed)
+ local pool = viewerState.iconPool
+ local existing = #pool
+ if needed <= existing then
+ return
+ end
+ local size = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
+ for i = existing + 1, needed do
+ pool[i] = createExtraIcon(viewerState.container, size)
+ end
+end
+
+--------------------------------------------------------------------------------
+-- Module Methods
+--------------------------------------------------------------------------------
+
+--- Creates the invisible parent frame and per-viewer containers.
+---@return Frame parent The parent frame.
+function ExtraIcons:CreateFrame()
+ local parent = CreateFrame("Frame", "ECMExtraIcons", UIParent)
+ parent:SetFrameStrata("MEDIUM")
+ parent:SetSize(1, 1)
+
+ self._viewers = {}
+ for viewerKey in pairs(VIEWER_REGISTRY) do
+ local container = CreateFrame("Frame", "ECMExtraIcons_" .. viewerKey, parent)
+ container:SetFrameStrata("MEDIUM")
+ container:SetSize(1, 1)
+ self._viewers[viewerKey] = {
+ container = container,
+ iconPool = {},
+ originalPoint = nil,
+ hooked = false,
+ }
+ end
+
+ return parent
+end
+
+--- Override ShouldShow to check module enabled state and at least one viewer visible.
+---@return boolean shouldShow Whether we should attempt layout.
+function ExtraIcons:ShouldShow()
+ if not BarMixin.FrameProto.ShouldShow(self) then
+ return false
+ end
+ for _, reg in pairs(VIEWER_REGISTRY) do
+ local blizzFrame = _G[reg.blizzFrameKey]
+ if blizzFrame and blizzFrame:IsShown() then
+ return true
+ end
+ end
+ return false
+end
+
+--- Lays out icons for a single viewer.
+---@param viewerKey string The viewer key ("utility" or "main").
+---@param entries table[] The config entries for this viewer.
+---@param isEditing boolean Whether edit mode is active.
+---@return boolean changed Whether any icons were placed.
+function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
+ local reg = VIEWER_REGISTRY[viewerKey]
+ local blizzFrame = _G[reg.blizzFrameKey]
+ local vs = self._viewers[viewerKey]
+ if not vs then
+ return false
+ end
+ local container = vs.container
+
+ -- Resolve entries to displayable items
+ local items
+ if not blizzFrame or not blizzFrame:IsShown() or isEditing or #entries == 0 then
+ items = {}
+ else
+ items = resolveViewerEntries(entries)
+ end
+
+ if #items == 0 then
+ -- Restore viewer position and hide container
+ local p = vs.originalPoint
+ if p and blizzFrame then
+ blizzFrame:ClearAllPoints()
+ blizzFrame:SetPoint(p[1], p[2], p[3], p[4], p[5])
+ end
+ if isEditing then
+ vs.originalPoint = nil
+ end
+ container:Hide()
+ return false
+ end
+
+ -- Hide all existing pool icons
+ for _, icon in ipairs(vs.iconPool) do
+ icon:Hide()
+ end
+
+ -- Ensure pool is large enough
+ ensurePoolSize(vs, #items)
+
+ local siblingFontPath, siblingFontSize, siblingFontFlags = getSiblingCooldownNumberFont(blizzFrame)
+ local iconSize = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
+ local spacing = 0
+ local viewerScale = 1.0
+ local lastActiveItemFrame = nil
+ local numActiveViewerIcons = 0
+
+ if blizzFrame:IsShown() then
+ viewerScale = blizzFrame.iconScale or 1.0
+ spacing = blizzFrame.childXPadding or 0
+
+ if blizzFrame.GetItemFrames then
+ for _, itemFrame in ipairs(blizzFrame:GetItemFrames()) do
+ if itemFrame.isActive then
+ iconSize = itemFrame:GetWidth() or iconSize
+ lastActiveItemFrame = itemFrame
+ numActiveViewerIcons = numActiveViewerIcons + 1
+ end
+ end
+ else
+ for _, child in ipairs({ blizzFrame:GetChildren() }) do
+ if child and child:IsShown() and child.GetSpellID then
+ iconSize = child:GetWidth() or iconSize
+ break
+ end
+ end
+ end
+ end
+
+ container:SetScale(viewerScale)
+
+ local numItems = #items
+ local totalWidth = numItems * iconSize + (numItems - 1) * spacing
+ container:SetSize(totalWidth, iconSize)
+
+ if not vs.originalPoint then
+ local point, relativeTo, relativePoint, x, y = blizzFrame:GetPoint()
+ vs.originalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
+ end
+
+ -- Shift the Blizzard viewer left to keep the combined group centred
+ local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
+ local viewerWidth = numActiveViewerIcons > 0 and blizzFrame:GetWidth() or 0
+ local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
+
+ local p = vs.originalPoint
+ blizzFrame:ClearAllPoints()
+ blizzFrame:SetPoint(p[1], p[2], p[3], p[4] + viewerOffsetX, p[5])
+
+ -- Position and configure each icon
+ local borderScale = ns.Constants.EXTRA_ICON_BORDER_SCALE
+ local xOffset = 0
+ for i, iconData in ipairs(items) do
+ local icon = vs.iconPool[i]
+ icon:SetSize(iconSize, iconSize)
+ icon.Icon:SetSize(iconSize, iconSize)
+ icon.Mask:SetSize(iconSize, iconSize)
+ icon.Border:SetSize(iconSize * borderScale, iconSize * borderScale)
+ icon.slotId = iconData.slotId
+ icon.itemId = iconData.itemId
+ icon.spellId = iconData.spellId
+
+ icon.Icon:SetTexture(iconData.texture)
+ icon:ClearAllPoints()
+ icon:SetPoint("LEFT", container, "LEFT", xOffset, 0)
+ icon:Show()
+
+ updateIconCooldown(icon)
+
+ if siblingFontPath and siblingFontSize then
+ applyCooldownNumberFont(icon, siblingFontPath, siblingFontSize, siblingFontFlags)
+ end
+
+ xOffset = xOffset + iconSize + spacing
+ end
+
+ container:ClearAllPoints()
+ container:SetPoint("LEFT", lastActiveItemFrame or blizzFrame, "RIGHT", spacing, 0)
+ container:Show()
+
+ return true
+end
+
+--- Override UpdateLayout to position icons for all viewers.
+---@param why string|nil Reason for layout update.
+---@return boolean success Whether any viewer had icons placed.
+function ExtraIcons:UpdateLayout(why)
+ if not self.InnerFrame or not self._viewers then
+ return false
+ end
+
+ local shouldShow = self:ShouldShow()
+ local moduleConfig = self:GetModuleConfig()
+ local isEditing = self._isEditModeActive
+ if isEditing == nil then
+ local editModeManager = _G.EditModeManagerFrame
+ isEditing = editModeManager and editModeManager:IsShown() or false
+ end
+
+ -- When hidden, nil-out viewers so every _updateSingleViewer call gets
+ -- empty entries, which restores Blizzard viewer positions and hides
+ -- the extra-icon containers.
+ local viewers = shouldShow and moduleConfig and moduleConfig.viewers
+ local anyPlaced = false
+
+ for viewerKey in pairs(VIEWER_REGISTRY) do
+ local entries = viewers and viewers[viewerKey] or {}
+ if self:_updateSingleViewer(viewerKey, entries, isEditing) then
+ anyPlaced = true
+ end
+ end
+
+ -- Manage InnerFrame visibility (not handled by ApplyFramePosition since
+ -- ExtraIcons overrides UpdateLayout without calling the base).
+ if shouldShow then
+ if not self.InnerFrame:IsShown() then
+ self.InnerFrame:Show()
+ end
+ else
+ self.InnerFrame:Hide()
+ end
+
+ if anyPlaced then
+ ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")")
+ self:ThrottledRefresh("UpdateLayout")
+ end
+
+ return anyPlaced
+end
+
+--- Override Refresh to update cooldown states on all viewers.
+function ExtraIcons:Refresh(why, force)
+ if not BarMixin.FrameProto.Refresh(self, why, force) then
+ return false
+ end
+
+ if not self._viewers then
+ return false
+ end
+
+ local anyRefreshed = false
+ for _, vs in pairs(self._viewers) do
+ local container = vs.container
+ if container and container:IsShown() then
+ for _, icon in ipairs(vs.iconPool) do
+ if icon:IsShown() and (icon.slotId or icon.itemId or icon.spellId) then
+ updateIconCooldown(icon)
+ end
+ end
+ anyRefreshed = true
+ end
+ end
+
+ if anyRefreshed then
+ ns.Log(self.Name, "Refresh complete (" .. (why or "") .. ")")
+ end
+ return anyRefreshed
+end
+
+--------------------------------------------------------------------------------
+-- Event Handlers
+--------------------------------------------------------------------------------
+
+function ExtraIcons:OnBagUpdateCooldown()
+ if self._viewers then
+ self:ThrottledRefresh("OnBagUpdateCooldown")
+ end
+end
+
+function ExtraIcons:OnBagUpdateDelayed()
+ ns.Runtime.RequestLayout("ExtraIcons:OnBagUpdateDelayed")
+end
+
+function ExtraIcons:OnPlayerEquipmentChanged(_, slotId)
+ if self._trackedEquipSlots and self._trackedEquipSlots[slotId] then
+ ns.Runtime.RequestLayout("ExtraIcons:OnPlayerEquipmentChanged")
+ end
+end
+
+function ExtraIcons:OnPlayerEnteringWorld()
+ ns.Runtime.RequestLayout("ExtraIcons:OnPlayerEnteringWorld")
+end
+
+function ExtraIcons:OnSpellsChanged()
+ ns.Runtime.RequestLayout("ExtraIcons:OnSpellsChanged")
+end
+
+--- Rebuild the set of tracked equipment slots from the current config.
+function ExtraIcons:_rebuildTrackedSlots()
+ local tracked = {}
+ local config = self:GetModuleConfig()
+ if config and config.viewers then
+ for _, entries in pairs(config.viewers) do
+ for _, entry in ipairs(entries) do
+ local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
+ local kind = stack and stack.kind or entry.kind
+ local sid = stack and stack.slotId or entry.slotId
+ if kind == "equipSlot" and sid then
+ tracked[sid] = true
+ end
+ end
+ end
+ end
+ self._trackedEquipSlots = tracked
+end
+
+--------------------------------------------------------------------------------
+-- Hooks
+--------------------------------------------------------------------------------
+
+--- Hook EditModeManagerFrame to pause layout while edit mode is active.
+function ExtraIcons:HookEditMode()
+ local editModeManager = _G.EditModeManagerFrame
+ if not editModeManager or self._editModeHooked then
+ return
+ end
+
+ self._editModeHooked = true
+ self._isEditModeActive = editModeManager:IsShown()
+
+ editModeManager:HookScript("OnShow", function()
+ self._isEditModeActive = true
+ if self._viewers then
+ for _, vs in pairs(self._viewers) do
+ vs.container:Hide()
+ end
+ end
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:EnterEditMode")
+ end
+ end)
+
+ editModeManager:HookScript("OnHide", function()
+ self._isEditModeActive = false
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:ExitEditMode")
+ end
+ end)
+end
+
+--- Hook a single Blizzard viewer frame.
+---@param viewerKey string The viewer key.
+function ExtraIcons:_hookViewer(viewerKey)
+ local reg = VIEWER_REGISTRY[viewerKey]
+ local blizzFrame = _G[reg.blizzFrameKey]
+ local vs = self._viewers and self._viewers[viewerKey]
+ if not blizzFrame or not vs or vs.hooked then
+ return
+ end
+
+ vs.hooked = true
+
+ blizzFrame:HookScript("OnShow", function()
+ ns.Runtime.RequestLayout("ExtraIcons:OnShow")
+ end)
+
+ blizzFrame:HookScript("OnHide", function()
+ if vs.container then
+ vs.container:Hide()
+ end
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:OnHide")
+ end
+ end)
+
+ blizzFrame:HookScript("OnSizeChanged", function()
+ ns.Runtime.RequestLayout("ExtraIcons:OnSizeChanged")
+ end)
+
+ ns.Log(self.Name, "Hooked " .. reg.blizzFrameKey)
+end
+
+--- Hook the UtilityCooldownViewer (backward compat wrapper).
+function ExtraIcons:HookUtilityViewer()
+ self:_hookViewer("utility")
+end
+
+--------------------------------------------------------------------------------
+-- Lifecycle
+--------------------------------------------------------------------------------
+
+function ExtraIcons:OnInitialize()
+ BarMixin.AddFrameMixin(self, "ExtraIcons")
+end
+
+function ExtraIcons:OnEnable()
+ self:EnsureFrame()
+ ns.Runtime.RegisterFrame(self)
+ self:_rebuildTrackedSlots()
+
+ self:RegisterEvent("BAG_UPDATE_COOLDOWN", function(_, ...) self:OnBagUpdateCooldown(...) end)
+ self:RegisterEvent("BAG_UPDATE_DELAYED", function(_, ...) self:OnBagUpdateDelayed(...) end)
+ self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED", function(_, ...) self:OnPlayerEquipmentChanged(...) end)
+ self:RegisterEvent("PLAYER_ENTERING_WORLD", function(_, ...) self:OnPlayerEnteringWorld(...) end)
+ self:RegisterEvent("SPELLS_CHANGED", function() self:OnSpellsChanged() end)
+ self:RegisterEvent("SPELL_UPDATE_COOLDOWN", function() self:ThrottledRefresh("OnSpellUpdateCooldown") end)
+
+ -- Hook viewers after a short delay to ensure Blizzard frames are loaded
+ C_Timer.After(0.1, function()
+ self:HookEditMode()
+ for viewerKey in pairs(VIEWER_REGISTRY) do
+ self:_hookViewer(viewerKey)
+ end
+ ns.Runtime.RequestLayout("ExtraIcons:OnEnable")
+ end)
+end
+
+function ExtraIcons:OnDisable()
+ self:UnregisterAllEvents()
+ self:UpdateLayout("OnDisable")
+
+ ns.Runtime.UnregisterFrame(self)
+
+ if self._viewers then
+ for _, vs in pairs(self._viewers) do
+ vs.originalPoint = nil
+ end
+ end
+ self._isEditModeActive = nil
+ self._trackedEquipSlots = nil
+end
diff --git a/Modules/ItemIcons.lua b/Modules/ItemIcons.lua
deleted file mode 100644
index c226aac0..00000000
--- a/Modules/ItemIcons.lua
+++ /dev/null
@@ -1,490 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local _, ns = ...
-local BarMixin = ns.BarMixin
-local ItemIcons = ns.Addon:NewModule("ItemIcons")
-ns.Addon.ItemIcons = ItemIcons
-
----@class ECM_ItemIconsModule : ECM_FrameProto
-
----@class ECM_IconData
----@field itemId number Item ID.
----@field texture string|number Icon texture.
----@field slotId number|nil Inventory slot ID (trinkets only, nil for bag items).
-
----@class ECM_ItemIcon : Button
----@field slotId number|nil Inventory slot ID this icon represents (trinkets only).
----@field itemId number|nil Item ID this icon represents (bag items only).
----@field Icon Texture The icon texture.
----@field Cooldown Cooldown The cooldown overlay frame.
-
---- Checks if a trinket slot has an on-use effect.
----@param slotId number Inventory slot ID (13 or 14).
----@return ECM_IconData|nil iconData Icon data if on-use, nil otherwise.
-local function getTrinketData(slotId)
- local itemId = GetInventoryItemID("player", slotId)
- if not itemId then
- return nil
- end
-
- local _, spellId = C_Item.GetItemSpell(itemId)
- if not spellId then
- return nil
- end
-
- local texture = GetInventoryItemTexture("player", slotId)
- if not texture then
- return nil
- end
-
- return {
- itemId = itemId,
- texture = texture,
- slotId = slotId,
- }
-end
-
---- Returns the first item from priorityList that exists in the player's bags.
----@param priorityList { itemID: number, quality: number|nil }[] Array of priority entries, ordered by priority.
----@return ECM_IconData|nil iconData Icon data if found, nil otherwise.
-local function getBestConsumable(priorityList)
- for _, entry in ipairs(priorityList) do
- local itemId = entry.itemID
- if C_Item.GetItemCount(itemId) > 0 then
- local texture = C_Item.GetItemIconByID(itemId)
- if texture then
- return {
- itemId = itemId,
- texture = texture,
- }
- end
- end
- end
- return nil
-end
-
-local HEALTHSTONE_PRIORITY = ns.Constants.HEALTHSTONES
-
-local DISPLAY_ITEM_SOURCES = {
- { flag = "showTrinket1", getter = getTrinketData, arg = ns.Constants.TRINKET_SLOT_1 },
- { flag = "showTrinket2", getter = getTrinketData, arg = ns.Constants.TRINKET_SLOT_2 },
- { flag = "showCombatPotion", getter = getBestConsumable, arg = ns.Constants.COMBAT_POTIONS },
- { flag = "showHealthPotion", getter = getBestConsumable, arg = ns.Constants.HEALTH_POTIONS },
- { flag = "showHealthstone", getter = getBestConsumable, arg = HEALTHSTONE_PRIORITY },
-}
-
---- Returns all display items in display order: Trinkets > Combat Potion > Health Potion > Healthstone.
----@param moduleConfig table Module configuration.
----@return ECM_IconData[] items Array of icon data.
-local _displayItems = {}
-
-local function getDisplayItems(moduleConfig)
- wipe(_displayItems)
- for _, source in ipairs(DISPLAY_ITEM_SOURCES) do
- if moduleConfig[source.flag] then
- local item = source.getter(source.arg)
- if item then
- _displayItems[#_displayItems + 1] = item
- end
- end
- end
- return _displayItems
-end
-
---- Creates a single item icon frame styled like cooldown viewer icons.
----@param parent Frame Parent frame to attach to.
----@param size number Icon size in pixels.
----@return ECM_ItemIcon icon The created icon frame.
-local function createItemIcon(parent, size)
- local icon = CreateFrame("Button", nil, parent)
- icon:SetSize(size, size)
-
- -- Icon texture (the actual item icon) - ARTWORK layer
- icon.Icon = icon:CreateTexture(nil, "ARTWORK")
- icon.Icon:SetPoint("CENTER")
- icon.Icon:SetSize(size, size)
-
- -- Icon mask (rounds the corners) - ARTWORK layer
- icon.Mask = icon:CreateMaskTexture()
- icon.Mask:SetAtlas("UI-HUD-CoolDownManager-Mask")
- icon.Mask:SetPoint("CENTER")
- icon.Mask:SetSize(size, size)
- icon.Icon:AddMaskTexture(icon.Mask)
-
- -- Cooldown overlay with proper swipe and edge textures
- icon.Cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
- icon.Cooldown:SetAllPoints()
- icon.Cooldown:SetDrawEdge(true)
- icon.Cooldown:SetDrawSwipe(true)
- icon.Cooldown:SetHideCountdownNumbers(false)
- icon.Cooldown:SetSwipeTexture([[Interface\HUD\UI-HUD-CoolDownManager-Icon-Swipe]], 0, 0, 0, 0.2)
- icon.Cooldown:SetEdgeTexture([[Interface\Cooldown\UI-HUD-ActionBar-SecondaryCooldown]])
-
- -- Border overlay - OVERLAY layer (scaled size, centered)
- icon.Border = icon:CreateTexture(nil, "OVERLAY")
- icon.Border:SetAtlas("UI-HUD-CoolDownManager-IconOverlay")
- icon.Border:SetPoint("CENTER")
- icon.Border:SetSize(size * ns.Constants.ITEM_ICON_BORDER_SCALE, size * ns.Constants.ITEM_ICON_BORDER_SCALE)
-
- -- Shadow overlay
- icon.Shadow = icon:CreateTexture(nil, "OVERLAY")
- icon.Shadow:SetAtlas("UI-CooldownManager-OORshadow")
- icon.Shadow:SetAllPoints()
- icon.Shadow:Hide() -- Only show when out of range (optional)
-
- return icon
-end
-
---- Updates the cooldown display on an item icon.
----@param icon ECM_ItemIcon The icon to update.
-local function updateIconCooldown(icon)
- local start, duration, enable
-
- if icon.slotId then
- -- Trinket (equipped item): enable is number (0/1)
- start, duration, enable = GetInventoryItemCooldown("player", icon.slotId)
- enable = (enable == 1)
- elseif icon.itemId then
- -- Bag item (potion/healthstone): enable is boolean
- start, duration, enable = C_Item.GetItemCooldown(icon.itemId)
- else
- return
- end
-
- if enable and duration > 0 then
- icon.Cooldown:SetCooldown(start, duration)
- else
- icon.Cooldown:Clear()
- end
-end
-
---- Gets cooldown number font info from a Blizzard utility cooldown icon.
---- Caches the result on the viewer to avoid per-layout table allocation and child scan.
---- @param utilityViewer Frame
---- @return string|nil fontPath, number|nil fontSize, string|nil fontFlags
-local function getSiblingCooldownNumberFont(utilityViewer)
- local cached = utilityViewer.__ecmCDFont
- if cached then
- return cached[1], cached[2], cached[3]
- end
-
- for _, child in ipairs({ utilityViewer:GetChildren() }) do
- local cooldown = child.Cooldown
- if cooldown and cooldown.GetRegions then
- local region = cooldown:GetRegions()
- if region and region.IsObjectType and region:IsObjectType("FontString") and region.GetFont then
- local fontPath, fontSize, fontFlags = region:GetFont()
- if fontPath and fontSize then
- utilityViewer.__ecmCDFont = { fontPath, fontSize, fontFlags }
- return fontPath, fontSize, fontFlags
- end
- end
- end
- end
-end
-
---- Applies cooldown number font settings to one icon cooldown.
---- @param icon ECM_ItemIcon
---- @param fontPath string
---- @param fontSize number
---- @param fontFlags string|nil
-local function applyCooldownNumberFont(icon, fontPath, fontSize, fontFlags)
- local region = icon.Cooldown:GetRegions()
- if region and region.IsObjectType and region:IsObjectType("FontString") and region.SetFont then
- region:SetFont(fontPath, fontSize, fontFlags)
- end
-end
-
---- Override CreateFrame to create the container for item icons.
----@return Frame container The container frame.
-function ItemIcons:CreateFrame()
- local frame = CreateFrame("Frame", "ECMItemIcons", UIParent)
- frame:SetFrameStrata("MEDIUM")
- frame:SetSize(1, 1) -- Will be resized in UpdateLayout
-
- -- Pool of icon frames (pre-allocate for max items)
- frame._iconPool = {}
- local initialSize = ns.Constants.DEFAULT_ITEM_ICON_SIZE
- for i = 1, ns.Constants.ITEM_ICONS_MAX do
- frame._iconPool[i] = createItemIcon(frame, initialSize)
- end
-
- return frame
-end
-
---- Override ShouldShow to check module enabled state and item availability.
----@return boolean shouldShow Whether the frame should be shown.
-function ItemIcons:ShouldShow()
- if not BarMixin.FrameProto.ShouldShow(self) then
- return false
- end
- local utilityViewer = _G["UtilityCooldownViewer"]
- return utilityViewer ~= nil and utilityViewer:IsShown()
-end
-
---- Override UpdateLayout to position icons relative to UtilityCooldownViewer.
---- @param why string|nil Reason for layout update (for logging/debugging).
---- @return boolean success Whether the layout was applied.
-function ItemIcons:UpdateLayout(why)
- local frame = self.InnerFrame
- if not frame then
- return false
- end
-
- local moduleConfig = self:GetModuleConfig()
- local utilityViewer = _G["UtilityCooldownViewer"]
- local isEditing = self._isEditModeActive
- if isEditing == nil then
- local editModeManager = _G.EditModeManagerFrame
- isEditing = editModeManager and editModeManager:IsShown() or false
- end
-
- local items = (not moduleConfig or isEditing or not self:ShouldShow()) and {} or getDisplayItems(moduleConfig)
-
- if #items == 0 then
- local p = self._viewerOriginalPoint
- if p and utilityViewer then
- utilityViewer:ClearAllPoints()
- utilityViewer:SetPoint(p[1], p[2], p[3], p[4], p[5])
- end
- if isEditing then
- self._viewerOriginalPoint = nil
- end
- frame:Hide()
- return false
- end
-
- for _, icon in ipairs(frame._iconPool) do
- icon:Hide()
- end
-
- local siblingFontPath, siblingFontSize, siblingFontFlags = getSiblingCooldownNumberFont(utilityViewer)
- local iconSize = ns.Constants.DEFAULT_ITEM_ICON_SIZE
- local spacing = 0
- local viewerScale = 1.0
- local lastActiveItemFrame = nil
- local numActiveViewerIcons = 0
-
- if utilityViewer:IsShown() then
- viewerScale = utilityViewer.iconScale or 1.0
- -- Blizzard's managed layout uses childXPadding for actual icon positioning.
- -- This differs from the Edit Mode iconPadding setting by a constant offset of -4
- -- (childXPadding = iconPadding - 4), accounting for transparent padding in icon atlases.
- spacing = utilityViewer.childXPadding or 0
-
- -- GetItemFrames reports only managed slots; isActive flags visible ones.
- -- This avoids anchoring to a stale viewer frame that reserves space for inactive icons.
- if utilityViewer.GetItemFrames then
- for _, itemFrame in ipairs(utilityViewer:GetItemFrames()) do
- if itemFrame.isActive then
- iconSize = itemFrame:GetWidth() or iconSize
- lastActiveItemFrame = itemFrame
- numActiveViewerIcons = numActiveViewerIcons + 1
- end
- end
- else
- for _, child in ipairs({ utilityViewer:GetChildren() }) do
- if child and child:IsShown() and child.GetSpellID then
- iconSize = child:GetWidth() or iconSize
- break
- end
- end
- end
- end
-
- frame:SetScale(viewerScale)
-
- local numItems = #items
- local totalWidth = numItems * iconSize + (numItems - 1) * spacing
- frame:SetSize(totalWidth, iconSize)
-
- if not self._viewerOriginalPoint then
- local point, relativeTo, relativePoint, x, y = utilityViewer:GetPoint()
- self._viewerOriginalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
- end
-
- -- The viewer is shifted left to keep the combined group (viewer icons + ECM icons) centred.
- -- When GetItemFrames is available, the viewer frame width may be stale (inactive slots still
- -- occupying frame width), so we correct for dead space using the actual active content width.
- local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
- local viewerWidth = numActiveViewerIcons > 0 and utilityViewer:GetWidth() or 0
- local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
-
- local p = self._viewerOriginalPoint
- utilityViewer:ClearAllPoints()
- utilityViewer:SetPoint(p[1], p[2], p[3], p[4] + viewerOffsetX, p[5])
-
- -- Position and configure each icon
- local borderScale = ns.Constants.ITEM_ICON_BORDER_SCALE
- local xOffset = 0
- for i, iconData in ipairs(items) do
- local icon = frame._iconPool[i]
- icon:SetSize(iconSize, iconSize)
- icon.Icon:SetSize(iconSize, iconSize)
- icon.Mask:SetSize(iconSize, iconSize)
- icon.Border:SetSize(iconSize * borderScale, iconSize * borderScale)
- icon.slotId = iconData.slotId
- icon.itemId = iconData.itemId
-
- icon.Icon:SetTexture(iconData.texture)
- icon:ClearAllPoints()
- icon:SetPoint("LEFT", frame, "LEFT", xOffset, 0)
- icon:Show()
-
- -- Apply cooldown immediately; ThrottledRefresh may be suppressed
- -- when BAG_UPDATE_COOLDOWN and BAG_UPDATE_DELAYED fire in the same batch.
- updateIconCooldown(icon)
-
- if siblingFontPath and siblingFontSize then
- applyCooldownNumberFont(icon, siblingFontPath, siblingFontSize, siblingFontFlags)
- end
-
- xOffset = xOffset + iconSize + spacing
- end
-
- -- Anchor to the last active item frame rather than the viewer frame, since the viewer
- -- frame width can be stale (reserving space for inactive icons) and cause a spurious gap.
- frame:ClearAllPoints()
- frame:SetPoint("LEFT", lastActiveItemFrame or utilityViewer, "RIGHT", spacing, 0)
- frame:Show()
-
- ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")")
- self:ThrottledRefresh("UpdateLayout")
-
- return true
-end
-
---- Override Refresh to update cooldown states.
-function ItemIcons:Refresh(why, force)
- -- call the frame mixin to check pre-conditions
- if not BarMixin.FrameProto.Refresh(self, why, force) then
- return false
- end
-
- local frame = self.InnerFrame
- if not frame or not frame:IsShown() then
- return false
- end
-
- -- Update cooldowns on all visible icons
- for _, icon in ipairs(frame._iconPool) do
- if icon:IsShown() and (icon.slotId or icon.itemId) then
- updateIconCooldown(icon)
- end
- end
-
- ns.Log(self.Name, "Refresh complete (" .. (why or "") .. ")")
- return true
-end
-
-function ItemIcons:OnBagUpdateCooldown()
- if self.InnerFrame then
- self:ThrottledRefresh("OnBagUpdateCooldown")
- end
-end
-
-function ItemIcons:OnBagUpdateDelayed()
- -- Bag contents changed, which consumables to show may have changed
- ns.Runtime.RequestLayout("ItemIcons:OnBagUpdateDelayed")
-end
-
-function ItemIcons:OnPlayerEquipmentChanged(_, slotId)
- -- Only update if a trinket slot changed
- if slotId == ns.Constants.TRINKET_SLOT_1 or slotId == ns.Constants.TRINKET_SLOT_2 then
- ns.Runtime.RequestLayout("ItemIcons:OnPlayerEquipmentChanged")
- end
-end
-
-function ItemIcons:OnPlayerEnteringWorld()
- ns.Runtime.RequestLayout("ItemIcons:OnPlayerEnteringWorld")
-end
-
---- Hook EditModeManagerFrame to pause ItemIcons layout while edit mode is active.
-function ItemIcons:HookEditMode()
- local editModeManager = _G.EditModeManagerFrame
- if not editModeManager or self._editModeHooked then
- return
- end
-
- self._editModeHooked = true
- self._isEditModeActive = editModeManager:IsShown()
-
- editModeManager:HookScript("OnShow", function()
- self._isEditModeActive = true
- if self.InnerFrame then
- self.InnerFrame:Hide()
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:EnterEditMode")
- end
- end)
-
- editModeManager:HookScript("OnHide", function()
- self._isEditModeActive = false
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:ExitEditMode")
- end
- end)
-end
-
---- Hook the UtilityCooldownViewer to update when it shows/hides or resizes.
-function ItemIcons:HookUtilityViewer()
- local utilityViewer = _G["UtilityCooldownViewer"]
- if not utilityViewer or self._viewerHooked then
- return
- end
-
- self._viewerHooked = true
-
- utilityViewer:HookScript("OnShow", function()
- ns.Runtime.RequestLayout("ItemIcons:OnShow")
- end)
-
- utilityViewer:HookScript("OnHide", function()
- if self.InnerFrame then
- self.InnerFrame:Hide()
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:OnHide")
- end
- end)
-
- utilityViewer:HookScript("OnSizeChanged", function()
- ns.Runtime.RequestLayout("ItemIcons:OnSizeChanged")
- end)
-
- ns.Log(self.Name, "Hooked UtilityCooldownViewer")
-end
-
-function ItemIcons:OnInitialize()
- BarMixin.AddFrameMixin(self, "ItemIcons")
-end
-
-function ItemIcons:OnEnable()
- self:EnsureFrame()
- ns.Runtime.RegisterFrame(self)
-
- self:RegisterEvent("BAG_UPDATE_COOLDOWN", function(_, ...) self:OnBagUpdateCooldown(...) end) -- very noisy but required for cooldown updates on bag items
- self:RegisterEvent("BAG_UPDATE_DELAYED", function(_, ...) self:OnBagUpdateDelayed(...) end)
- self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED", function(_, ...) self:OnPlayerEquipmentChanged(...) end)
- self:RegisterEvent("PLAYER_ENTERING_WORLD", function(_, ...) self:OnPlayerEnteringWorld(...) end)
-
- -- Hook the utility viewer after a short delay to ensure Blizzard frames are loaded
- C_Timer.After(0.1, function()
- self:HookEditMode()
- self:HookUtilityViewer()
- ns.Runtime.RequestLayout("ItemIcons:OnEnable")
- end)
-end
-
-function ItemIcons:OnDisable()
- self:UnregisterAllEvents()
- self:UpdateLayout("OnDisable")
-
- ns.Runtime.UnregisterFrame(self)
-
- self._viewerOriginalPoint = nil
- self._isEditModeActive = nil
-end
diff --git a/Tests/Migration_spec.lua b/Tests/Migration_spec.lua
index eae4c34c..6e8974a5 100644
--- a/Tests/Migration_spec.lua
+++ b/Tests/Migration_spec.lua
@@ -137,7 +137,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_nil(persistedColor.keyType)
assert.is_nil(persistedColor.spellID)
assert.is_nil(persistedColor.cooldownID)
@@ -171,7 +171,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.are.equal("spellID", spellIDEntry.meta.keyType)
assert.are.equal(2468, spellIDEntry.meta.spellID)
assert.are.equal("cooldownID", cooldownEntry.meta.keyType)
@@ -205,7 +205,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.are.same(byNameEntry, profile.buffBars.colors.byName[12][2]["Do Not Replace"])
assert.are.same(byNameEntry, profile.buffBars.colors.bySpellID[12][2][1001])
assert.are.same(byNameEntry, profile.buffBars.colors.byCooldownID[12][2][1002])
@@ -219,7 +219,7 @@ describe("Migration", function()
schemaVersion = 8,
}
Migration.Run(noBuffBars)
- assert.are.equal(11, noBuffBars.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, noBuffBars.schemaVersion)
local invalidColors = {
schemaVersion = 8,
@@ -228,7 +228,7 @@ describe("Migration", function()
},
}
Migration.Run(invalidColors)
- assert.are.equal(11, invalidColors.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidColors.schemaVersion)
assert.are.equal("invalid", invalidColors.buffBars.colors)
local invalidByName = {
@@ -243,7 +243,7 @@ describe("Migration", function()
},
}
Migration.Run(invalidByName)
- assert.are.equal(11, invalidByName.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidByName.schemaVersion)
assert.is_table(invalidByName.buffBars.colors.byName)
end)
@@ -283,7 +283,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
local summary = assert(searchLogMessages("V10 spell color normalization summary:"))
local tierBreakdown = assert(searchLogMessages("V10 tier breakdown:"))
local created = assert(searchLogMessages("V10 created missing tier stores: byCooldownID, byTexture"))
@@ -373,7 +373,7 @@ describe("Migration", function()
local noBuffBars = { schemaVersion = 9 }
Migration.Run(noBuffBars)
- assert.are.equal(11, noBuffBars.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, noBuffBars.schemaVersion)
assert.is_not_nil(searchLogMessages("V10 spell color normalization skipped: buffBars.colors missing"))
assert.is_nil(searchLogMessages("V10 spell color normalization summary:"))
assert.is_nil(searchLogMessages("V10 tier breakdown:"))
@@ -386,7 +386,7 @@ describe("Migration", function()
}
Migration.Run(invalidColors)
- assert.are.equal(11, invalidColors.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidColors.schemaVersion)
assert.is_not_nil(searchLogMessages("V10 spell color normalization skipped: buffBars.colors missing"))
assert.is_nil(searchLogMessages("V10 spell color normalization summary:"))
assert.is_nil(searchLogMessages("V10 tier breakdown:"))
@@ -415,7 +415,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
local summary = assert(searchLogMessages("V10 spell color normalization summary:"))
local tierBreakdown = assert(searchLogMessages("V10 tier breakdown:"))
local specAnomalies = select(2, searchLogMessages("V10 anomaly: class=12 spec="))
@@ -462,7 +462,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_table(profile.buffBars.colors.bySpellID)
assert.is_table(profile.buffBars.colors.byCooldownID)
assert.is_table(profile.buffBars.colors.byTexture)
@@ -498,7 +498,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.are.equal("spellName", byNameEntry.meta.keyType)
assert.are.equal("KeepNameMetadata", byNameEntry.meta.spellName)
assert.are.equal("spellID", bySpellIDEntry.meta.keyType)
@@ -546,7 +546,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_table(profile.buffBars.colors.bySpellID[12][2])
assert.is_table(profile.buffBars.colors.byCooldownID[12][2])
assert.is_table(profile.buffBars.colors.byTexture[12][2])
@@ -577,7 +577,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.are.equal("spellName", byNameEntry.meta.keyType)
assert.are.equal(9001, byNameEntry.meta.spellID)
assert.are.equal(9002, byNameEntry.meta.cooldownID)
@@ -604,7 +604,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_nil(spellIDEntry.value.keyType)
assert.is_nil(spellIDEntry.value.spellID)
assert.is_nil(profile.buffBars.colors.byCooldownID)
@@ -629,7 +629,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
local winner = profile.buffBars.colors.bySpellID[12][2][203720]
assert.are.same(bySpellEntry, winner)
assert.are.same(winner, profile.buffBars.colors.byName[12][2]["Demon Spikes"])
@@ -658,7 +658,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
-- powerBar: both offsets migrated, cleared
assert.is_nil(profile.powerBar.offsetX)
@@ -706,7 +706,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_nil(profile.powerBar.editModePositions)
assert.is_nil(profile.resourceBar.editModePositions)
assert.is_nil(profile.runeBar.editModePositions)
@@ -901,7 +901,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
local expected = { point = "CENTER", x = 0, y = -275 }
assert.same(expected, profile.powerBar.editModePositions.Modern)
assert.same(expected, profile.powerBar.editModePositions.Classic)
@@ -927,7 +927,7 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
local expected = { point = "CENTER", x = 0, y = -275 }
assert.same(expected, profile.powerBar.editModePositions.Modern)
assert.same(expected, profile.powerBar.editModePositions.Classic)
@@ -947,12 +947,155 @@ describe("Migration", function()
Migration.Run(profile)
- assert.are.equal(11, profile.schemaVersion)
+ assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
assert.is_nil(profile.powerBar.editModePositions)
assert.are.equal(-275, profile.powerBar.offsetY)
assert.is_not_nil(searchLogMessages("V11 no layouts available; skipping position migration"))
end)
+ -- V12: itemIcons boolean flags → extraIcons.viewers structure
+
+ it("V12 migrates all-true itemIcons flags to extraIcons.viewers.utility", function()
+ local profile = {
+ schemaVersion = 11,
+ itemIcons = {
+ enabled = true,
+ showTrinket1 = true,
+ showTrinket2 = true,
+ showCombatPotion = true,
+ showHealthPotion = true,
+ showHealthstone = true,
+ },
+ }
+
+ Migration.Run(profile)
+
+ assert.are.equal(12, profile.schemaVersion)
+ assert.is_nil(profile.itemIcons)
+ assert.is_true(profile.extraIcons.enabled)
+ assert.same({
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ }, profile.extraIcons.viewers.utility)
+ assert.same({}, profile.extraIcons.viewers.main)
+ end)
+
+ it("V12 omits disabled flags from the viewer list", function()
+ local profile = {
+ schemaVersion = 11,
+ itemIcons = {
+ enabled = true,
+ showTrinket1 = true,
+ showTrinket2 = false,
+ showCombatPotion = false,
+ showHealthPotion = true,
+ showHealthstone = true,
+ },
+ }
+
+ Migration.Run(profile)
+
+ assert.are.equal(12, profile.schemaVersion)
+ assert.same({
+ { stackKey = "trinket1" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ }, profile.extraIcons.viewers.utility)
+ end)
+
+ it("V12 preserves enabled=false", function()
+ local profile = {
+ schemaVersion = 11,
+ itemIcons = {
+ enabled = false,
+ showTrinket1 = true,
+ showTrinket2 = true,
+ showCombatPotion = true,
+ showHealthPotion = true,
+ showHealthstone = true,
+ },
+ }
+
+ Migration.Run(profile)
+
+ assert.is_false(profile.extraIcons.enabled)
+ end)
+
+ it("V12 treats nil flags as true (default on)", function()
+ local profile = {
+ schemaVersion = 11,
+ itemIcons = { enabled = true },
+ }
+
+ Migration.Run(profile)
+
+ assert.are.equal(12, profile.schemaVersion)
+ assert.same({
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ }, profile.extraIcons.viewers.utility)
+ end)
+
+ it("V12 seeds default extraIcons when itemIcons section is missing", function()
+ local profile = {
+ schemaVersion = 11,
+ }
+
+ Migration.Run(profile)
+
+ assert.are.equal(12, profile.schemaVersion)
+ assert.is_nil(profile.itemIcons)
+ assert.is_true(profile.extraIcons.enabled)
+ assert.are.equal(5, #profile.extraIcons.viewers.utility)
+ assert.same({}, profile.extraIcons.viewers.main)
+ end)
+
+ it("V12 does not overwrite existing extraIcons when itemIcons is missing", function()
+ local profile = {
+ schemaVersion = 11,
+ extraIcons = {
+ enabled = false,
+ viewers = { utility = { { stackKey = "trinket1" } }, main = {} },
+ },
+ }
+
+ Migration.Run(profile)
+
+ assert.are.equal(12, profile.schemaVersion)
+ assert.is_false(profile.extraIcons.enabled)
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ end)
+
+ it("V12 logs the migrated stack keys", function()
+ local profile = {
+ schemaVersion = 11,
+ itemIcons = {
+ enabled = true,
+ showTrinket1 = true,
+ showTrinket2 = false,
+ showCombatPotion = true,
+ showHealthPotion = false,
+ showHealthstone = true,
+ },
+ }
+
+ Migration.Run(profile)
+
+ local logMsg = searchLogMessages("V12 migrated itemIcons")
+ assert.is_not_nil(logMsg)
+ assert.is_not_nil(string.find(logMsg, "trinket1", 1, true))
+ assert.is_not_nil(string.find(logMsg, "combatPotions", 1, true))
+ assert.is_not_nil(string.find(logMsg, "healthstones", 1, true))
+ assert.is_nil(string.find(logMsg, "trinket2", 1, true))
+ assert.is_nil(string.find(logMsg, "healthPotions", 1, true))
+ end)
+
it("ValidateRollback rejects non-integer target versions", function()
local current = ns.Constants.CURRENT_SCHEMA_VERSION
_G[ns.Constants.SV_NAME] = {
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
new file mode 100644
index 00000000..6b466a25
--- /dev/null
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -0,0 +1,1007 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers =
+ assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
+local EditModeManagerFrame
+local UtilityCooldownViewer
+local EssentialCooldownViewer
+local makeHookableFrame = TestHelpers.makeHookableFrame
+
+describe("ExtraIcons", function()
+ local originalGlobals
+ local ns
+
+ local CAPTURED_GLOBALS = {
+ "EditModeManagerFrame",
+ "UtilityCooldownViewer",
+ "EssentialCooldownViewer",
+ }
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals(CAPTURED_GLOBALS)
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ local function makeExtraIcons()
+ local mod = {
+ Name = "ExtraIcons",
+ _viewers = {
+ utility = {
+ container = TestHelpers.makeFrame({ shown = true }),
+ iconPool = {},
+ originalPoint = nil,
+ hooked = false,
+ },
+ main = {
+ container = TestHelpers.makeFrame({ shown = true }),
+ iconPool = {},
+ originalPoint = nil,
+ hooked = false,
+ },
+ },
+ _editModeHooked = nil,
+ }
+
+ function mod:IsEnabled()
+ return true
+ end
+
+ function mod:UnregisterAllEvents() end
+
+ function mod:UpdateLayout() end
+
+ function mod:HookEditMode()
+ local editModeManager = _G.EditModeManagerFrame
+ if not editModeManager or self._editModeHooked then
+ return
+ end
+
+ self._editModeHooked = true
+ self._isEditModeActive = editModeManager:IsShown()
+
+ editModeManager:HookScript("OnShow", function()
+ self._isEditModeActive = true
+ if self._viewers then
+ for _, vs in pairs(self._viewers) do
+ vs.container:Hide()
+ end
+ end
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:EnterEditMode")
+ end
+ end)
+
+ editModeManager:HookScript("OnHide", function()
+ self._isEditModeActive = false
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:ExitEditMode")
+ end
+ end)
+ end
+
+ function mod:_hookViewer(viewerKey)
+ local registry = {
+ utility = { blizzFrameKey = "UtilityCooldownViewer" },
+ main = { blizzFrameKey = "EssentialCooldownViewer" },
+ }
+ local reg = registry[viewerKey]
+ local blizzFrame = _G[reg.blizzFrameKey]
+ local vs = self._viewers and self._viewers[viewerKey]
+ if not blizzFrame or not vs or vs.hooked then
+ return
+ end
+ vs.hooked = true
+
+ blizzFrame:HookScript("OnShow", function()
+ ns.Runtime.RequestLayout("ExtraIcons:OnShow")
+ end)
+
+ blizzFrame:HookScript("OnHide", function()
+ if vs.container then
+ vs.container:Hide()
+ end
+ if self:IsEnabled() then
+ ns.Runtime.RequestLayout("ExtraIcons:OnHide")
+ end
+ end)
+
+ blizzFrame:HookScript("OnSizeChanged", function()
+ ns.Runtime.RequestLayout("ExtraIcons:OnSizeChanged")
+ end)
+ end
+
+ function mod:HookUtilityViewer()
+ self:_hookViewer("utility")
+ end
+
+ function mod:OnDisable()
+ self:UnregisterAllEvents()
+ self:UpdateLayout("OnDisable")
+
+ ns.Runtime.UnregisterFrame(self)
+
+ if self._viewers then
+ for _, vs in pairs(self._viewers) do
+ vs.originalPoint = nil
+ end
+ end
+ self._isEditModeActive = nil
+ self._trackedEquipSlots = nil
+ end
+
+ return mod
+ end
+
+ before_each(function()
+ ns = {
+ Runtime = {
+ UnregisterFrame = function() end,
+ RequestLayout = function() end,
+ },
+ }
+ EditModeManagerFrame = makeHookableFrame(false)
+ UtilityCooldownViewer = makeHookableFrame(true)
+ EssentialCooldownViewer = makeHookableFrame(true)
+ _G.EditModeManagerFrame = EditModeManagerFrame
+ _G.UtilityCooldownViewer = UtilityCooldownViewer
+ _G.EssentialCooldownViewer = EssentialCooldownViewer
+ end)
+
+ describe("hook lifecycle", function()
+ it("keeps hook guards set across disable cycles", function()
+ local mod = makeExtraIcons()
+
+ mod:HookEditMode()
+ mod:_hookViewer("utility")
+
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
+
+ mod:OnDisable()
+ mod:HookEditMode()
+ mod:_hookViewer("utility")
+
+ assert.is_true(mod._editModeHooked)
+ assert.is_true(mod._viewers.utility.hooked)
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
+ end)
+ end)
+end)
+
+describe("ExtraIcons real source", function()
+ local originalGlobals
+ local ExtraIcons
+ local ns
+ local createdCooldowns
+ local registerFrameCalls
+ local addMixinCalls
+ local timerCallbacks
+ local inventoryItemBySlot
+ local inventorySpellByItem
+ local inventoryTextureBySlot
+ local inventoryCooldownBySlot
+ local itemCounts
+ local itemIconsByID
+ local itemCooldownByID
+ local playerSpells
+ local spellTextures
+ local spellCooldowns
+ local spellCooldownInfos
+ local spellCharges
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "EditModeManagerFrame",
+ "UtilityCooldownViewer",
+ "EssentialCooldownViewer",
+ "UIParent",
+ "CreateFrame",
+ "C_Timer",
+ "C_Spell",
+ "GetInventoryItemID",
+ "GetInventoryItemTexture",
+ "GetInventoryItemCooldown",
+ "C_Item",
+ "IsPlayerSpell",
+ })
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ createdCooldowns = {}
+ registerFrameCalls = 0
+ addMixinCalls = 0
+ timerCallbacks = {}
+ inventoryItemBySlot = {}
+ inventorySpellByItem = {}
+ inventoryTextureBySlot = {}
+ inventoryCooldownBySlot = {}
+ itemCounts = {}
+ itemIconsByID = {}
+ itemCooldownByID = {}
+ playerSpells = {}
+ spellTextures = {}
+ spellCooldowns = {}
+ spellCooldownInfos = {}
+ spellCharges = {}
+ ns = {
+ Log = function() end,
+ BarMixin = {
+ FrameProto = {
+ ShouldShow = function()
+ return true
+ end,
+ Refresh = function()
+ return true
+ end,
+ },
+ AddFrameMixin = function(target)
+ addMixinCalls = addMixinCalls + 1
+ target.EnsureFrame = target.EnsureFrame or function() end
+ end,
+ },
+ Runtime = {
+ RegisterFrame = function()
+ registerFrameCalls = registerFrameCalls + 1
+ end,
+ UnregisterFrame = function() end,
+ RequestLayout = function() end,
+ },
+ }
+ TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
+ TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
+ _G.UIParent = TestHelpers.makeFrame({ name = "UIParent" })
+ EditModeManagerFrame = TestHelpers.makeHookableFrame(false)
+ UtilityCooldownViewer = TestHelpers.makeHookableFrame(true)
+ EssentialCooldownViewer = TestHelpers.makeHookableFrame(true)
+ _G.EditModeManagerFrame = EditModeManagerFrame
+ _G.UtilityCooldownViewer = UtilityCooldownViewer
+ _G.EssentialCooldownViewer = EssentialCooldownViewer
+ _G.C_Timer = {
+ After = function(_, callback)
+ timerCallbacks[#timerCallbacks + 1] = callback
+ end,
+ }
+ _G.GetInventoryItemID = function(_, slotId)
+ return inventoryItemBySlot[slotId]
+ end
+ _G.GetInventoryItemTexture = function(_, slotId)
+ return inventoryTextureBySlot[slotId]
+ end
+ _G.GetInventoryItemCooldown = function(_, slotId)
+ local cooldown = inventoryCooldownBySlot[slotId] or { 0, 0, 0 }
+ return cooldown[1], cooldown[2], cooldown[3]
+ end
+ _G.C_Item = {
+ GetItemSpell = function(itemId)
+ return nil, inventorySpellByItem[itemId]
+ end,
+ GetItemCount = function(itemId)
+ return itemCounts[itemId] or 0
+ end,
+ GetItemIconByID = function(itemId)
+ return itemIconsByID[itemId]
+ end,
+ GetItemCooldown = function(itemId)
+ local cooldown = itemCooldownByID[itemId] or { 0, 0, false }
+ return cooldown[1], cooldown[2], cooldown[3]
+ end,
+ }
+ _G.IsPlayerSpell = function(spellId)
+ return playerSpells[spellId] or false
+ end
+ _G.C_Spell = {
+ GetSpellTexture = function(spellId)
+ return spellTextures[spellId]
+ end,
+ GetSpellCooldown = function(spellId)
+ return spellCooldownInfos[spellId]
+ end,
+ GetSpellCooldownDuration = function(spellId)
+ return spellCooldowns[spellId]
+ end,
+ GetSpellCharges = function(spellId)
+ return spellCharges[spellId]
+ end,
+ GetSpellChargeDuration = function(spellId)
+ return spellCooldowns[spellId]
+ end,
+ }
+ _G.CreateFrame = function(frameType)
+ local frame = TestHelpers.makeFrame({ shown = true })
+ frame.SetFrameStrata = function() end
+ frame.SetSize = function(self, width, height)
+ self:SetWidth(width)
+ self:SetHeight(height)
+ end
+ frame.SetScale = function(self, scale)
+ self.__scale = scale
+ end
+ frame.GetScale = function(self)
+ return self.__scale or 1
+ end
+ frame.CreateTexture = function()
+ local texture = TestHelpers.makeTexture()
+ texture.SetPoint = function() end
+ texture.SetSize = function() end
+ texture.SetAtlas = function() end
+ texture.AddMaskTexture = function() end
+ texture.Hide = function(self)
+ self.__hidden = true
+ end
+ return texture
+ end
+ frame.CreateMaskTexture = function()
+ local texture = TestHelpers.makeTexture()
+ texture.SetAtlas = function() end
+ texture.SetPoint = function() end
+ texture.SetSize = function() end
+ return texture
+ end
+ frame.SetAllPoints = function() end
+ frame.SetDrawEdge = function() end
+ frame.SetDrawSwipe = function() end
+ frame.SetHideCountdownNumbers = function() end
+ frame.SetSwipeTexture = function() end
+ frame.SetEdgeTexture = function() end
+ frame.Clear = function(self)
+ self.__cleared = true
+ end
+ frame.SetCooldown = function(self, start, duration)
+ self.__cooldown = { start, duration }
+ end
+ frame.SetCooldownFromDurationObject = function(self, durObj)
+ self.__durObj = durObj
+ end
+ frame.__fontRegion = TestHelpers.makeRegion("FontString")
+ frame.__fontRegion.SetFont = function(self, path, size, flags)
+ self.__font = { path, size, flags }
+ end
+ frame.__fontRegion.GetFont = function(self)
+ return unpack(self.__font or {})
+ end
+ frame.GetRegions = function(self)
+ if frameType == "Cooldown" then
+ return self.__fontRegion
+ end
+ return
+ end
+ if frameType == "Cooldown" then
+ createdCooldowns[#createdCooldowns + 1] = frame
+ end
+ return frame
+ end
+
+ ns.Addon = {
+ NewModule = function(self, name)
+ local module = { Name = name }
+ self[name] = module
+ return module
+ end,
+ }
+
+ _G.wipe = function(t) for k in pairs(t) do t[k] = nil end return t end
+
+ TestHelpers.LoadChunk("Modules/ExtraIcons.lua", "Unable to load Modules/ExtraIcons.lua")(nil, ns)
+ ExtraIcons = assert(ns.Addon.ExtraIcons, "ExtraIcons module did not initialize")
+ function ExtraIcons:IsEnabled()
+ return true
+ end
+ function ExtraIcons:ThrottledRefresh() end
+ end)
+
+ local function makeViewersConfig(utilityStacks, mainStacks)
+ return {
+ viewers = {
+ utility = utilityStacks or {},
+ main = mainStacks or {},
+ },
+ }
+ end
+
+ it("requires at least one viewer to be visible in ShouldShow", function()
+ assert.is_true(ExtraIcons:ShouldShow())
+
+ UtilityCooldownViewer:Hide()
+ assert.is_true(ExtraIcons:ShouldShow())
+
+ EssentialCooldownViewer:Hide()
+ assert.is_false(ExtraIcons:ShouldShow())
+ end)
+
+ it("only triggers layout for tracked equipment slot changes", function()
+ local reasons = {}
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons._trackedEquipSlots = { [13] = true, [14] = true }
+
+ ExtraIcons:OnPlayerEquipmentChanged(nil, 1)
+ ExtraIcons:OnPlayerEquipmentChanged(nil, 13)
+ ExtraIcons:OnPlayerEquipmentChanged(nil, 14)
+
+ assert.same({ "ExtraIcons:OnPlayerEquipmentChanged", "ExtraIcons:OnPlayerEquipmentChanged" }, reasons)
+ end)
+
+ it("rebuilds tracked equipment slots from config", function()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig(
+ { { stackKey = "trinket1" }, { stackKey = "healthstones" } },
+ { { stackKey = "trinket2" } }
+ )
+ end
+
+ ExtraIcons:_rebuildTrackedSlots()
+
+ assert.is_true(ExtraIcons._trackedEquipSlots[13])
+ assert.is_true(ExtraIcons._trackedEquipSlots[14])
+ assert.is_nil(ExtraIcons._trackedEquipSlots[1])
+ end)
+
+ it("hooks edit mode only once", function()
+ ExtraIcons:HookEditMode()
+ ExtraIcons:HookEditMode()
+
+ assert.is_true(ExtraIcons._editModeHooked)
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
+ assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
+ end)
+
+ it("hooks viewers only once", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+
+ ExtraIcons:_hookViewer("utility")
+ ExtraIcons:_hookViewer("utility")
+
+ assert.is_true(ExtraIcons._viewers.utility.hooked)
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
+ assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
+ end)
+
+ it("creates on-demand icon pool when needed", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ local vs = ExtraIcons._viewers.utility
+ assert.are.equal(0, #vs.iconPool)
+
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.is_true(#vs.iconPool >= 1)
+ end)
+
+ it("only refreshes cooldowns when viewers exist", function()
+ local reasons = {}
+ function ExtraIcons:ThrottledRefresh(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons._viewers = nil
+ ExtraIcons:OnBagUpdateCooldown()
+ assert.same({}, reasons)
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons:OnBagUpdateCooldown()
+ assert.same({ "OnBagUpdateCooldown" }, reasons)
+ end)
+
+ it("edit mode callbacks toggle state and defer layout", function()
+ local reasons = {}
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons:HookEditMode()
+ EditModeManagerFrame._hooks.OnShow[1]()
+ EditModeManagerFrame._hooks.OnHide[1]()
+
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+ assert.is_false(ExtraIcons._viewers.main.container:IsShown())
+ assert.is_false(ExtraIcons._isEditModeActive)
+ assert.same({ "ExtraIcons:EnterEditMode", "ExtraIcons:ExitEditMode" }, reasons)
+ end)
+
+ it("viewer callbacks hide the container and defer layout", function()
+ local reasons = {}
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons:_hookViewer("utility")
+ UtilityCooldownViewer._hooks.OnShow[1]()
+ UtilityCooldownViewer._hooks.OnHide[1]()
+ UtilityCooldownViewer._hooks.OnSizeChanged[1]()
+
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+ assert.same({ "ExtraIcons:OnShow", "ExtraIcons:OnHide", "ExtraIcons:OnSizeChanged" }, reasons)
+ end)
+
+ it("returns false from UpdateLayout when frame or config is missing", function()
+ ExtraIcons.InnerFrame = nil
+ assert.is_false(ExtraIcons:UpdateLayout("test"))
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function() return nil end
+ assert.is_false(ExtraIcons:UpdateLayout("test"))
+ end)
+
+ it("hides InnerFrame and restores viewers when ShouldShow is false", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "trinket1" } })
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
+ ExtraIcons._viewers.utility.originalPoint = { "CENTER", UIParent, "CENTER", 10, 20 }
+
+ -- Hide both viewers so ShouldShow returns false
+ UtilityCooldownViewer:Hide()
+ EssentialCooldownViewer:Hide()
+
+ assert.is_false(ExtraIcons:UpdateLayout("test"))
+ assert.is_false(ExtraIcons.InnerFrame:IsShown())
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+
+ -- Viewer position restored
+ local point, _, _, x, y = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal("CENTER", point)
+ assert.are.equal(10, x)
+ assert.are.equal(20, y)
+ end)
+
+ it("re-shows InnerFrame after a hide cycle", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ -- First layout: icons placed, InnerFrame shown
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.is_true(ExtraIcons.InnerFrame:IsShown())
+
+ -- Simulate global hide cycle (e.g. mounting or entering rest area)
+ ExtraIcons.InnerFrame:Hide()
+ UtilityCooldownViewer:Hide()
+ EssentialCooldownViewer:Hide()
+ assert.is_false(ExtraIcons:UpdateLayout("hidden"))
+ assert.is_false(ExtraIcons.InnerFrame:IsShown())
+
+ -- Restore viewers — simulates unmount / leaving rest area
+ UtilityCooldownViewer:Show()
+ EssentialCooldownViewer:Show()
+ assert.is_true(ExtraIcons:UpdateLayout("unhidden"))
+ assert.is_true(ExtraIcons.InnerFrame:IsShown())
+ assert.is_true(ExtraIcons._viewers.utility.container:IsShown())
+ end)
+
+ it("returns false from UpdateLayout during live edit mode and restores viewer", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "trinket1" } })
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
+ ExtraIcons._viewers.utility.originalPoint = { "CENTER", UIParent, "CENTER", 10, 20 }
+ ExtraIcons._isEditModeActive = nil
+ EditModeManagerFrame:Show()
+
+ assert.is_false(ExtraIcons:UpdateLayout("test"))
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+ assert.is_nil(ExtraIcons._viewers.utility.originalPoint)
+
+ local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal("CENTER", point)
+ assert.are.equal(UIParent, relativeTo)
+ assert.are.equal("CENTER", relativePoint)
+ assert.are.equal(10, x)
+ assert.are.equal(20, y)
+ end)
+
+ it("defers layout for delayed bag and world events", function()
+ local reasons = {}
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons:OnBagUpdateDelayed()
+ ExtraIcons:OnPlayerEnteringWorld()
+
+ assert.same({ "ExtraIcons:OnBagUpdateDelayed", "ExtraIcons:OnPlayerEnteringWorld" }, reasons)
+ end)
+
+ it("registers with the frame system and schedules initial hooks on enable", function()
+ local reasons = {}
+ function ExtraIcons:RegisterEvent() end
+ function ExtraIcons:GetModuleConfig() return makeViewersConfig() end
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons:OnInitialize()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons:OnEnable()
+ assert.are.equal(1, addMixinCalls)
+ assert.are.equal(1, registerFrameCalls)
+ assert.are.equal(1, #timerCallbacks)
+
+ timerCallbacks[1]()
+
+ assert.same({ "ExtraIcons:OnEnable" }, reasons)
+ assert.is_true(ExtraIcons._editModeHooked)
+ assert.is_true(ExtraIcons._viewers.utility.hooked)
+ assert.is_true(ExtraIcons._viewers.main.hooked)
+ end)
+
+ it("lays out display items using utility viewer sizing and copies cooldown fonts", function()
+ local utilityFontRegion = TestHelpers.makeRegion("FontString")
+ utilityFontRegion.GetFont = function()
+ return "Fonts\\FRIZQT__.TTF", 17, "OUTLINE"
+ end
+ local utilityCooldown = {
+ GetRegions = function()
+ return utilityFontRegion
+ end,
+ }
+ local utilityFontChild = {
+ Cooldown = utilityCooldown,
+ IsShown = function()
+ return false
+ end,
+ }
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function()
+ return 12345
+ end
+ UtilityCooldownViewer.childXPadding = 6
+ UtilityCooldownViewer.iconScale = 1.25
+ UtilityCooldownViewer._children = { utilityFontChild, utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+ inventoryItemBySlot[14] = 102
+ inventoryTextureBySlot[14] = "trinket-2"
+ inventorySpellByItem[102] = 9002
+ itemCounts[ns.Constants.COMBAT_POTIONS[1].itemID] = 3
+ itemIconsByID[ns.Constants.COMBAT_POTIONS[1].itemID] = "combat-potion"
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthstones" },
+ })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.is_true(vs.container:IsShown())
+ assert.are.equal(1.25, vs.container.__scale)
+ assert.are.equal((4 * 18) + (3 * 6), vs.container:GetWidth())
+ assert.are.equal(18, vs.container:GetHeight())
+ assert.are.equal(101, vs.iconPool[1].itemId)
+ assert.are.equal(13, vs.iconPool[1].slotId)
+ assert.are.equal(102, vs.iconPool[2].itemId)
+ assert.are.equal(14, vs.iconPool[2].slotId)
+ assert.are.equal(ns.Constants.COMBAT_POTIONS[1].itemID, vs.iconPool[3].itemId)
+ assert.are.equal(ns.Constants.HEALTHSTONE_ITEM_ID, vs.iconPool[4].itemId)
+ assert.same(
+ { "Fonts\\FRIZQT__.TTF", 17, "OUTLINE" },
+ vs.iconPool[1].Cooldown.__fontRegion.__font
+ )
+
+ local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal("CENTER", point)
+ assert.are.equal(UIParent, relativeTo)
+ assert.are.equal("CENTER", relativePoint)
+ assert.are.equal(40.75, x)
+ assert.are.equal(50, y)
+ end)
+
+ it("uses GetItemFrames and isActive to detect icon size and anchor container", function()
+ local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
+ activeFrame.isActive = true
+ local inactiveFrame = TestHelpers.makeFrame({ shown = false, width = 22, height = 22 })
+ inactiveFrame.isActive = false
+ UtilityCooldownViewer.childXPadding = 4
+ UtilityCooldownViewer.iconScale = 1.0
+ UtilityCooldownViewer:SetWidth(22)
+ UtilityCooldownViewer.GetItemFrames = function()
+ return { inactiveFrame, activeFrame }
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.are.equal(22, vs.container:GetWidth())
+ local _, anchorFrame = vs.container:GetPoint(1)
+ assert.are.equal(activeFrame, anchorFrame)
+ local _, _, _, x = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal(87, x)
+ end)
+
+ it("prefers demonic healthstone over the legacy healthstone", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID] = "demonic-healthstone"
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.are.equal(ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID, ExtraIcons._viewers.utility.iconPool[1].itemId)
+ end)
+
+ it("anchors container to last active item frame when viewer layout is stale", function()
+ local staleFrame = TestHelpers.makeFrame({ shown = false, width = 22, height = 22 })
+ staleFrame.isActive = false
+ local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
+ activeFrame.isActive = true
+ UtilityCooldownViewer.childXPadding = 2
+ UtilityCooldownViewer.iconScale = 1.0
+ UtilityCooldownViewer:SetWidth(46)
+ UtilityCooldownViewer.GetItemFrames = function()
+ return { staleFrame, activeFrame }
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "trinket1" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local _, anchorFrame = ExtraIcons._viewers.utility.container:GetPoint(1)
+ assert.are.equal(activeFrame, anchorFrame)
+ local _, _, _, x = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal(100, x)
+ end)
+
+ it("restores the viewer and hides the container when no items are available", function()
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({
+ { stackKey = "trinket1" },
+ { stackKey = "trinket2" },
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ })
+ end
+ ExtraIcons._viewers.utility.originalPoint = { "CENTER", UIParent, "CENTER", 10, 20 }
+
+ assert.is_false(ExtraIcons:UpdateLayout("test"))
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+
+ local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal("CENTER", point)
+ assert.are.equal(UIParent, relativeTo)
+ assert.are.equal("CENTER", relativePoint)
+ assert.are.equal(10, x)
+ assert.are.equal(20, y)
+ end)
+
+ it("applies cooldowns during UpdateLayout so throttled refresh cannot skip them", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+ itemCooldownByID[ns.Constants.HEALTHSTONE_ITEM_ID] = { 100, 60, true }
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.same({ 100, 60 }, ExtraIcons._viewers.utility.iconPool[1].Cooldown.__cooldown)
+ end)
+
+ it("refreshes cooldowns for visible icons across viewers", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ local vs = ExtraIcons._viewers.utility
+ for i = 1, 3 do
+ vs.iconPool[i] = TestHelpers.makeFrame({ shown = true })
+ vs.iconPool[i].Cooldown = {
+ SetCooldown = function(self, start, duration)
+ self.__cooldown = { start, duration }
+ end,
+ Clear = function(self)
+ self.__cleared = true
+ end,
+ GetRegions = function() return nil end,
+ }
+ end
+ vs.iconPool[1].slotId = 13
+ vs.iconPool[2].itemId = 5001
+ vs.iconPool[3].itemId = 5002
+ vs.container:Show()
+
+ inventoryCooldownBySlot[13] = { 10, 30, 1 }
+ itemCooldownByID[5001] = { 20, 40, true }
+ itemCooldownByID[5002] = { 0, 0, false }
+
+ assert.is_true(ExtraIcons:Refresh("test"))
+ assert.same({ 10, 30 }, vs.iconPool[1].Cooldown.__cooldown)
+ assert.same({ 20, 40 }, vs.iconPool[2].Cooldown.__cooldown)
+ assert.is_true(vs.iconPool[3].Cooldown.__cleared)
+ end)
+
+ it("resolves spell entries and tracks spell cooldowns", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ playerSpells[59752] = true
+ spellTextures[59752] = "racial-icon"
+ spellCooldowns[59752] = "durObj:59752"
+ spellCooldownInfos[59752] = { isOnGCD = false }
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { kind = "spell", ids = { { spellId = 59752 } } } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.are.equal(59752, vs.iconPool[1].spellId)
+ assert.same({ 0, 0 }, vs.iconPool[1].Cooldown.__cooldown)
+ assert.are.equal("durObj:59752", vs.iconPool[1].Cooldown.__durObj)
+ end)
+
+ it("skips spell cooldown swipe when only on GCD", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ playerSpells[59752] = true
+ spellTextures[59752] = "racial-icon"
+ spellCooldowns[59752] = "durObj:59752"
+ spellCooldownInfos[59752] = { isOnGCD = true }
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { kind = "spell", ids = { { spellId = 59752 } } } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.same({ 0, 0 }, vs.iconPool[1].Cooldown.__cooldown)
+ assert.is_nil(vs.iconPool[1].Cooldown.__durObj)
+ end)
+
+ it("uses charge duration for multi-charge spells", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ playerSpells[108853] = true
+ spellTextures[108853] = "fire-blast-icon"
+ spellCooldowns[108853] = "chargeDurObj:108853"
+ spellCooldownInfos[108853] = { isOnGCD = false }
+ spellCharges[108853] = { maxCharges = 2 }
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { kind = "spell", ids = { { spellId = 108853 } } } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.same({ 0, 0 }, vs.iconPool[1].Cooldown.__cooldown)
+ assert.are.equal("chargeDurObj:108853", vs.iconPool[1].Cooldown.__durObj)
+ end)
+
+ it("defers layout for spell change events", function()
+ local reasons = {}
+ ns.Runtime.RequestLayout = function(reason)
+ reasons[#reasons + 1] = reason
+ end
+
+ ExtraIcons:OnSpellsChanged()
+ assert.same({ "ExtraIcons:OnSpellsChanged" }, reasons)
+ end)
+
+ it("cleans up module state on disable", function()
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons._viewers.utility.originalPoint = { "TOP", UIParent, "TOP", 0, 0 }
+ ExtraIcons._isEditModeActive = true
+ ExtraIcons._trackedEquipSlots = { [13] = true }
+ local updateReasons = {}
+ function ExtraIcons:UnregisterAllEvents()
+ self._eventsUnregistered = true
+ end
+ function ExtraIcons:UpdateLayout(reason)
+ updateReasons[#updateReasons + 1] = reason
+ return false
+ end
+
+ ExtraIcons:OnDisable()
+
+ assert.is_true(ExtraIcons._eventsUnregistered)
+ assert.same({ "OnDisable" }, updateReasons)
+ assert.is_nil(ExtraIcons._viewers.utility.originalPoint)
+ assert.is_nil(ExtraIcons._isEditModeActive)
+ assert.is_nil(ExtraIcons._trackedEquipSlots)
+ end)
+end)
diff --git a/Tests/Modules/ItemIcons_spec.lua b/Tests/Modules/ItemIcons_spec.lua
deleted file mode 100644
index 33882e4e..00000000
--- a/Tests/Modules/ItemIcons_spec.lua
+++ /dev/null
@@ -1,787 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local TestHelpers =
- assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
-local EditModeManagerFrame
-local UtilityCooldownViewer
-local makeHookableFrame = TestHelpers.makeHookableFrame
-
-describe("ItemIcons", function()
- local originalGlobals
- local ns
-
- local CAPTURED_GLOBALS = {
- "EditModeManagerFrame",
- "UtilityCooldownViewer",
- }
-
- setup(function()
- originalGlobals = TestHelpers.CaptureGlobals(CAPTURED_GLOBALS)
- end)
-
- teardown(function()
- TestHelpers.RestoreGlobals(originalGlobals)
- end)
-
- local function makeItemIcons()
- local mod = {
- Name = "ItemIcons",
- InnerFrame = TestHelpers.makeFrame({ shown = true }),
- _layoutRetryCount = 2,
- _layoutRetryPending = true,
- _viewerOriginalPoint = { "TOP" },
- _viewerHooked = nil,
- _editModeHooked = nil,
- }
-
- function mod:IsEnabled()
- return true
- end
-
- function mod:UnregisterAllEvents() end
-
- function mod:UpdateLayout() end
-
- function mod:HookEditMode()
- local editModeManager = _G.EditModeManagerFrame
- if not editModeManager or self._editModeHooked then
- return
- end
-
- self._editModeHooked = true
- self._isEditModeActive = editModeManager:IsShown()
-
- editModeManager:HookScript("OnShow", function()
- self._isEditModeActive = true
- if self.InnerFrame then
- self.InnerFrame:Hide()
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:EnterEditMode")
- end
- end)
-
- editModeManager:HookScript("OnHide", function()
- self._isEditModeActive = false
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:ExitEditMode")
- end
- end)
- end
-
- function mod:HookUtilityViewer()
- local utilityViewer = _G.UtilityCooldownViewer
- if not utilityViewer or self._viewerHooked then
- return
- end
-
- self._viewerHooked = true
-
- utilityViewer:HookScript("OnShow", function()
- ns.Runtime.RequestLayout("ItemIcons:OnShow")
- end)
-
- utilityViewer:HookScript("OnHide", function()
- if self.InnerFrame then
- self.InnerFrame:Hide()
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ItemIcons:OnHide")
- end
- end)
-
- utilityViewer:HookScript("OnSizeChanged", function()
- ns.Runtime.RequestLayout("ItemIcons:OnSizeChanged")
- end)
- end
-
- function mod:OnDisable()
- self:UnregisterAllEvents()
- self:UpdateLayout("OnDisable")
-
- ns.Runtime.UnregisterFrame(self)
-
- self._viewerOriginalPoint = nil
- self._isEditModeActive = nil
- self._layoutRetryPending = nil
- self._layoutRetryCount = 0
- end
-
- return mod
- end
-
- before_each(function()
- ns = {
- Runtime = {
- UnregisterFrame = function() end,
- RequestLayout = function() end,
- },
- }
- EditModeManagerFrame = makeHookableFrame(false)
- UtilityCooldownViewer = makeHookableFrame(true)
- _G.EditModeManagerFrame = EditModeManagerFrame
- _G.UtilityCooldownViewer = UtilityCooldownViewer
- end)
-
- describe("hook lifecycle", function()
- it("keeps hook guards set across disable cycles", function()
- local mod = makeItemIcons()
-
- mod:HookEditMode()
- mod:HookUtilityViewer()
-
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
-
- mod:OnDisable()
- mod:HookEditMode()
- mod:HookUtilityViewer()
-
- assert.is_true(mod._editModeHooked)
- assert.is_true(mod._viewerHooked)
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
- end)
- end)
-end)
-
-describe("ItemIcons real source", function()
- local originalGlobals
- local ItemIcons
- local ns
- local createdCooldowns
- local registerFrameCalls
- local addMixinCalls
- local timerCallbacks
- local inventoryItemBySlot
- local inventorySpellByItem
- local inventoryTextureBySlot
- local inventoryCooldownBySlot
- local itemCounts
- local itemIconsByID
- local itemCooldownByID
-
- setup(function()
- originalGlobals = TestHelpers.CaptureGlobals({
- "EditModeManagerFrame",
- "UtilityCooldownViewer",
- "UIParent",
- "CreateFrame",
- "C_Timer",
- "GetInventoryItemID",
- "GetInventoryItemTexture",
- "GetInventoryItemCooldown",
- "C_Item",
- })
- end)
-
- teardown(function()
- TestHelpers.RestoreGlobals(originalGlobals)
- end)
-
- before_each(function()
- createdCooldowns = {}
- registerFrameCalls = 0
- addMixinCalls = 0
- timerCallbacks = {}
- inventoryItemBySlot = {}
- inventorySpellByItem = {}
- inventoryTextureBySlot = {}
- inventoryCooldownBySlot = {}
- itemCounts = {}
- itemIconsByID = {}
- itemCooldownByID = {}
- ns = {
- Log = function() end,
- BarMixin = {
- FrameProto = {
- ShouldShow = function()
- return true
- end,
- Refresh = function()
- return true
- end,
- },
- AddFrameMixin = function(target)
- addMixinCalls = addMixinCalls + 1
- target.EnsureFrame = target.EnsureFrame or function() end
- end,
- },
- Runtime = {
- RegisterFrame = function()
- registerFrameCalls = registerFrameCalls + 1
- end,
- UnregisterFrame = function() end,
- RequestLayout = function() end,
- },
- }
- TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
- TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
- _G.UIParent = TestHelpers.makeFrame({ name = "UIParent" })
- EditModeManagerFrame = makeHookableFrame(false)
- UtilityCooldownViewer = makeHookableFrame(true)
- _G.EditModeManagerFrame = EditModeManagerFrame
- _G.UtilityCooldownViewer = UtilityCooldownViewer
- _G.C_Timer = {
- After = function(_, callback)
- timerCallbacks[#timerCallbacks + 1] = callback
- end,
- }
- _G.GetInventoryItemID = function(_, slotId)
- return inventoryItemBySlot[slotId]
- end
- _G.GetInventoryItemTexture = function(_, slotId)
- return inventoryTextureBySlot[slotId]
- end
- _G.GetInventoryItemCooldown = function(_, slotId)
- local cooldown = inventoryCooldownBySlot[slotId] or { 0, 0, 0 }
- return cooldown[1], cooldown[2], cooldown[3]
- end
- _G.C_Item = {
- GetItemSpell = function(itemId)
- return nil, inventorySpellByItem[itemId]
- end,
- GetItemCount = function(itemId)
- return itemCounts[itemId] or 0
- end,
- GetItemIconByID = function(itemId)
- return itemIconsByID[itemId]
- end,
- GetItemCooldown = function(itemId)
- local cooldown = itemCooldownByID[itemId] or { 0, 0, false }
- return cooldown[1], cooldown[2], cooldown[3]
- end,
- }
- _G.CreateFrame = function(frameType)
- local frame = TestHelpers.makeFrame({ shown = true })
- frame.SetFrameStrata = function() end
- frame.SetSize = function(self, width, height)
- self:SetWidth(width)
- self:SetHeight(height)
- end
- frame.SetScale = function(self, scale)
- self.__scale = scale
- end
- frame.GetScale = function(self)
- return self.__scale or 1
- end
- frame.CreateTexture = function()
- local texture = TestHelpers.makeTexture()
- texture.SetPoint = function() end
- texture.SetSize = function() end
- texture.SetAtlas = function() end
- texture.AddMaskTexture = function() end
- texture.Hide = function(self)
- self.__hidden = true
- end
- return texture
- end
- frame.CreateMaskTexture = function()
- local texture = TestHelpers.makeTexture()
- texture.SetAtlas = function() end
- texture.SetPoint = function() end
- texture.SetSize = function() end
- return texture
- end
- frame.SetAllPoints = function() end
- frame.SetDrawEdge = function() end
- frame.SetDrawSwipe = function() end
- frame.SetHideCountdownNumbers = function() end
- frame.SetSwipeTexture = function() end
- frame.SetEdgeTexture = function() end
- frame.Clear = function(self)
- self.__cleared = true
- end
- frame.SetCooldown = function(self, start, duration)
- self.__cooldown = { start, duration }
- end
- frame.__fontRegion = TestHelpers.makeRegion("FontString")
- frame.__fontRegion.SetFont = function(self, path, size, flags)
- self.__font = { path, size, flags }
- end
- frame.__fontRegion.GetFont = function(self)
- return unpack(self.__font or {})
- end
- frame.GetRegions = function(self)
- if frameType == "Cooldown" then
- return self.__fontRegion
- end
- return
- end
- if frameType == "Cooldown" then
- createdCooldowns[#createdCooldowns + 1] = frame
- end
- return frame
- end
-
- ns.Addon = {
- NewModule = function(self, name)
- local module = { Name = name }
- self[name] = module
- return module
- end,
- }
-
- _G.wipe = function(t) for k in pairs(t) do t[k] = nil end return t end
-
- TestHelpers.LoadChunk("Modules/ItemIcons.lua", "Unable to load Modules/ItemIcons.lua")(nil, ns)
- ItemIcons = assert(ns.Addon.ItemIcons, "ItemIcons module did not initialize")
- function ItemIcons:IsEnabled()
- return true
- end
- function ItemIcons:ThrottledRefresh() end
- end)
-
- it("requires the utility viewer to be visible in ShouldShow", function()
- assert.is_true(ItemIcons:ShouldShow())
-
- UtilityCooldownViewer:Hide()
- assert.is_false(ItemIcons:ShouldShow())
- end)
-
- it("only triggers layout updates for trinket slot equipment changes", function()
- local reasons = {}
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:OnPlayerEquipmentChanged(nil, 1)
- ItemIcons:OnPlayerEquipmentChanged(nil, ns.Constants.TRINKET_SLOT_1)
- ItemIcons:OnPlayerEquipmentChanged(nil, ns.Constants.TRINKET_SLOT_2)
-
- assert.same({ "ItemIcons:OnPlayerEquipmentChanged", "ItemIcons:OnPlayerEquipmentChanged" }, reasons)
- end)
-
- it("registered equipment callback drops LibEvent target and forwards slotId", function()
- local captured = {}
- function ItemIcons:RegisterEvent(event, cb)
- captured[event] = cb
- end
- function ItemIcons:UnregisterAllEvents() end
- function ItemIcons:EnsureFrame() end
-
- local origRegister = ns.Runtime.RegisterFrame
- ns.Runtime.RegisterFrame = function() end
-
- ItemIcons:OnEnable()
-
- ns.Runtime.RegisterFrame = origRegister
-
- local reasons = {}
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- -- LibEvent dispatches cb(target, event, ...wowArgs)
- local cb = assert(captured["PLAYER_EQUIPMENT_CHANGED"], "expected PLAYER_EQUIPMENT_CHANGED registration")
- cb(ItemIcons, "PLAYER_EQUIPMENT_CHANGED", ns.Constants.TRINKET_SLOT_1)
- assert.same({ "ItemIcons:OnPlayerEquipmentChanged" }, reasons)
- end)
-
- it("hooks edit mode only once", function()
- ItemIcons:HookEditMode()
- ItemIcons:HookEditMode()
-
- assert.is_true(ItemIcons._editModeHooked)
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnShow"))
- assert.are.equal(1, EditModeManagerFrame:GetHookCount("OnHide"))
- end)
-
- it("hooks the utility viewer only once", function()
- ItemIcons:HookUtilityViewer()
- ItemIcons:HookUtilityViewer()
-
- assert.is_true(ItemIcons._viewerHooked)
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnShow"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnHide"))
- assert.are.equal(1, UtilityCooldownViewer:GetHookCount("OnSizeChanged"))
- end)
-
- it("preallocates the icon pool in CreateFrame", function()
- local frame = ItemIcons:CreateFrame()
-
- assert.are.equal(ns.Constants.ITEM_ICONS_MAX, #frame._iconPool)
- assert.are.equal(ns.Constants.DEFAULT_ITEM_ICON_SIZE, frame._iconPool[1]:GetWidth())
- assert.are.equal(ns.Constants.ITEM_ICONS_MAX, #createdCooldowns)
- end)
-
- it("only refreshes bag cooldowns when the frame exists", function()
- local reasons = {}
- function ItemIcons:ThrottledRefresh(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:OnBagUpdateCooldown()
- ItemIcons.InnerFrame = TestHelpers.makeFrame({ shown = true })
- ItemIcons:OnBagUpdateCooldown()
-
- assert.same({ "OnBagUpdateCooldown" }, reasons)
- end)
-
- it("edit mode callbacks toggle state and defer layout", function()
- local reasons = {}
- ItemIcons.InnerFrame = TestHelpers.makeFrame({ shown = true })
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:HookEditMode()
- EditModeManagerFrame._hooks.OnShow[1]()
- EditModeManagerFrame._hooks.OnHide[1]()
-
- assert.is_false(ItemIcons.InnerFrame:IsShown())
- assert.is_false(ItemIcons._isEditModeActive)
- assert.same({ "ItemIcons:EnterEditMode", "ItemIcons:ExitEditMode" }, reasons)
- end)
-
- it("utility viewer callbacks hide the frame and defer layout", function()
- local reasons = {}
- ItemIcons.InnerFrame = TestHelpers.makeFrame({ shown = true })
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:HookUtilityViewer()
- UtilityCooldownViewer._hooks.OnShow[1]()
- UtilityCooldownViewer._hooks.OnHide[1]()
- UtilityCooldownViewer._hooks.OnSizeChanged[1]()
-
- assert.is_false(ItemIcons.InnerFrame:IsShown())
- assert.same({ "ItemIcons:OnShow", "ItemIcons:OnHide", "ItemIcons:OnSizeChanged" }, reasons)
- end)
-
- it("returns false from UpdateLayout when the frame is missing or config is unavailable", function()
- ItemIcons.InnerFrame = nil
- assert.is_false(ItemIcons:UpdateLayout("test"))
-
- ItemIcons.InnerFrame = TestHelpers.makeFrame({ shown = true })
- ItemIcons.GetModuleConfig = function()
- return nil
- end
- assert.is_false(ItemIcons:UpdateLayout("test"))
- end)
-
- it("returns false from UpdateLayout during live edit mode and restores the viewer", function()
- ItemIcons.InnerFrame = TestHelpers.makeFrame({ shown = true })
- ItemIcons.GetModuleConfig = function()
- return { enabled = true }
- end
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
- ItemIcons._viewerOriginalPoint = { "CENTER", UIParent, "CENTER", 10, 20 }
- ItemIcons._isEditModeActive = nil
- EditModeManagerFrame:Show()
-
- assert.is_false(ItemIcons:UpdateLayout("test"))
- assert.is_false(ItemIcons.InnerFrame:IsShown())
- assert.is_nil(ItemIcons._viewerOriginalPoint)
-
- local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal("CENTER", point)
- assert.are.equal(UIParent, relativeTo)
- assert.are.equal("CENTER", relativePoint)
- assert.are.equal(10, x)
- assert.are.equal(20, y)
- end)
-
- it("defers layout for delayed bag and world events", function()
- local reasons = {}
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:OnBagUpdateDelayed()
- ItemIcons:OnPlayerEnteringWorld()
-
- assert.same({ "ItemIcons:OnBagUpdateDelayed", "ItemIcons:OnPlayerEnteringWorld" }, reasons)
- end)
-
- it("registers with the frame system and schedules initial hooks on enable", function()
- local reasons = {}
- function ItemIcons:RegisterEvent() end
- ns.Runtime.RequestLayout = function(reason)
- reasons[#reasons + 1] = reason
- end
-
- ItemIcons:OnInitialize()
- ItemIcons:OnEnable()
- assert.are.equal(1, addMixinCalls)
- assert.are.equal(1, registerFrameCalls)
- assert.are.equal(1, #timerCallbacks)
-
- timerCallbacks[1]()
-
- assert.same({ "ItemIcons:OnEnable" }, reasons)
- assert.is_true(ItemIcons._editModeHooked)
- assert.is_true(ItemIcons._viewerHooked)
- end)
-
- it("lays out display items using utility viewer sizing and copies cooldown fonts", function()
- local utilityFontRegion = TestHelpers.makeRegion("FontString")
- utilityFontRegion.GetFont = function()
- return "Fonts\\FRIZQT__.TTF", 17, "OUTLINE"
- end
- local utilityCooldown = {
- GetRegions = function()
- return utilityFontRegion
- end,
- }
- local utilityFontChild = {
- Cooldown = utilityCooldown,
- IsShown = function()
- return false
- end,
- }
- local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
- utilityIconChild.GetSpellID = function()
- return 12345
- end
- UtilityCooldownViewer.childXPadding = 6
- UtilityCooldownViewer.iconScale = 1.25
- UtilityCooldownViewer._children = { utilityFontChild, utilityIconChild }
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
-
- inventoryItemBySlot[ns.Constants.TRINKET_SLOT_1] = 101
- inventoryTextureBySlot[ns.Constants.TRINKET_SLOT_1] = "trinket-1"
- inventorySpellByItem[101] = 9001
- inventoryItemBySlot[ns.Constants.TRINKET_SLOT_2] = 102
- inventoryTextureBySlot[ns.Constants.TRINKET_SLOT_2] = "trinket-2"
- inventorySpellByItem[102] = 9002
- itemCounts[ns.Constants.COMBAT_POTIONS[1].itemID] = 3
- itemIconsByID[ns.Constants.COMBAT_POTIONS[1].itemID] = "combat-potion"
- itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
- itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
-
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return {
- showTrinket1 = true,
- showTrinket2 = true,
- showCombatPotion = true,
- showHealthPotion = false,
- showHealthstone = true,
- }
- end
-
- assert.is_true(ItemIcons:UpdateLayout("test"))
- assert.is_true(ItemIcons.InnerFrame:IsShown())
- assert.are.equal(1.25, ItemIcons.InnerFrame.__scale)
- assert.are.equal((4 * 18) + (3 * 6), ItemIcons.InnerFrame:GetWidth())
- assert.are.equal(18, ItemIcons.InnerFrame:GetHeight())
- assert.are.equal(101, ItemIcons.InnerFrame._iconPool[1].itemId)
- assert.are.equal(ns.Constants.TRINKET_SLOT_1, ItemIcons.InnerFrame._iconPool[1].slotId)
- assert.are.equal(102, ItemIcons.InnerFrame._iconPool[2].itemId)
- assert.are.equal(ns.Constants.TRINKET_SLOT_2, ItemIcons.InnerFrame._iconPool[2].slotId)
- assert.are.equal(ns.Constants.COMBAT_POTIONS[1].itemID, ItemIcons.InnerFrame._iconPool[3].itemId)
- assert.are.equal(ns.Constants.HEALTHSTONE_ITEM_ID, ItemIcons.InnerFrame._iconPool[4].itemId)
- assert.same(
- { "Fonts\\FRIZQT__.TTF", 17, "OUTLINE" },
- ItemIcons.InnerFrame._iconPool[1].Cooldown.__fontRegion.__font
- )
-
- local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal("CENTER", point)
- assert.are.equal(UIParent, relativeTo)
- assert.are.equal("CENTER", relativePoint)
- assert.are.equal(40.75, x)
- assert.are.equal(50, y)
- end)
-
- it("uses GetItemFrames and isActive to detect icon size and anchor container", function()
- local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
- activeFrame.isActive = true
- local inactiveFrame = TestHelpers.makeFrame({ shown = false, width = 22, height = 22 })
- inactiveFrame.isActive = false
- UtilityCooldownViewer.childXPadding = 4
- UtilityCooldownViewer.iconScale = 1.0
- UtilityCooldownViewer:SetWidth(22) -- 1 active icon, no stale space
- UtilityCooldownViewer.GetItemFrames = function()
- return { inactiveFrame, activeFrame }
- end
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
-
- itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
- itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
-
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return { showTrinket1 = false, showTrinket2 = false, showCombatPotion = false, showHealthPotion = false, showHealthstone = true }
- end
-
- assert.is_true(ItemIcons:UpdateLayout("test"))
- -- Icon size taken from active frame, not default
- assert.are.equal(22, ItemIcons.InnerFrame:GetWidth())
- -- Container anchors to the last active item frame, not the viewer
- local _, anchorFrame = ItemIcons.InnerFrame:GetPoint(1)
- assert.are.equal(activeFrame, anchorFrame)
- -- With no stale space: viewerOffsetX = (22 - 22 - 4 - 22) / 2 = -13, so x = 100 - 13 = 87
- local _, _, _, x = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal(87, x)
- end)
-
- it("prefers demonic healthstone over the legacy healthstone", function()
- local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
- utilityIconChild.GetSpellID = function()
- return 1
- end
- UtilityCooldownViewer.childXPadding = 0
- UtilityCooldownViewer.iconScale = 1
- UtilityCooldownViewer._children = { utilityIconChild }
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
-
- itemCounts[ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID] = 1
- itemIconsByID[ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID] = "demonic-healthstone"
- itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
- itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
-
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return {
- showTrinket1 = false,
- showTrinket2 = false,
- showCombatPotion = false,
- showHealthPotion = false,
- showHealthstone = true,
- }
- end
-
- assert.is_true(ItemIcons:UpdateLayout("test"))
- assert.are.equal(ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID, ItemIcons.InnerFrame._iconPool[1].itemId)
- end)
-
- it("anchors container to last active item frame when viewer layout is stale", function()
- -- Viewer frame is wider than its single active icon (stale layout: 2-icon width).
- local staleFrame = TestHelpers.makeFrame({ shown = false, width = 22, height = 22 })
- staleFrame.isActive = false
- local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
- activeFrame.isActive = true
- UtilityCooldownViewer.childXPadding = 2
- UtilityCooldownViewer.iconScale = 1.0
- UtilityCooldownViewer:SetWidth(46) -- stale: 2 * 22 + 2 spacing
- UtilityCooldownViewer.GetItemFrames = function()
- return { staleFrame, activeFrame }
- end
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
-
- inventoryItemBySlot[ns.Constants.TRINKET_SLOT_1] = 101
- inventoryTextureBySlot[ns.Constants.TRINKET_SLOT_1] = "trinket-1"
- inventorySpellByItem[101] = 9001
-
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return { showTrinket1 = true, showTrinket2 = false, showCombatPotion = false, showHealthPotion = false, showHealthstone = false }
- end
-
- assert.is_true(ItemIcons:UpdateLayout("test"))
- -- Container must anchor to activeFrame, not the viewer (which has stale/wider width)
- local _, anchorFrame = ItemIcons.InnerFrame:GetPoint(1)
- assert.are.equal(activeFrame, anchorFrame)
- -- Stale layout width is 46, with a single 22px icon and 2px padding on each side:
- -- (46 - 22 - 2 - 22) / 2 = 0 unused space, so the viewer's x-offset stays at its original 100.
- local _, _, _, x = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal(100, x) -- x should remain at the original SetPoint x-position
- end)
-
- it("restores the utility viewer and hides the frame when no items are available", function()
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 50)
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return {
- showTrinket1 = true,
- showTrinket2 = true,
- showCombatPotion = true,
- showHealthPotion = true,
- showHealthstone = true,
- }
- end
- ItemIcons._viewerOriginalPoint = { "CENTER", UIParent, "CENTER", 10, 20 }
-
- assert.is_false(ItemIcons:UpdateLayout("test"))
- assert.is_false(ItemIcons.InnerFrame:IsShown())
-
- local point, relativeTo, relativePoint, x, y = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal("CENTER", point)
- assert.are.equal(UIParent, relativeTo)
- assert.are.equal("CENTER", relativePoint)
- assert.are.equal(10, x)
- assert.are.equal(20, y)
- end)
-
- it("applies cooldowns during UpdateLayout so throttled refresh cannot skip them", function()
- local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
- utilityIconChild.GetSpellID = function()
- return 1
- end
- UtilityCooldownViewer.childXPadding = 0
- UtilityCooldownViewer.iconScale = 1
- UtilityCooldownViewer._children = { utilityIconChild }
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
-
- itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
- itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
- -- Healthstone has an active shared cooldown (e.g. health potion just used)
- itemCooldownByID[ns.Constants.HEALTHSTONE_ITEM_ID] = { 100, 60, true }
-
- ItemIcons.InnerFrame = ItemIcons:CreateFrame()
- ItemIcons.GetModuleConfig = function()
- return {
- showTrinket1 = false,
- showTrinket2 = false,
- showCombatPotion = false,
- showHealthPotion = false,
- showHealthstone = true,
- }
- end
-
- assert.is_true(ItemIcons:UpdateLayout("test"))
- -- Cooldown must be set directly in UpdateLayout, not deferred to ThrottledRefresh
- assert.same({ 100, 60 }, ItemIcons.InnerFrame._iconPool[1].Cooldown.__cooldown)
- end)
-
- it("refreshes cooldowns for visible trinket and bag icons", function()
- local frame = ItemIcons:CreateFrame()
- frame._iconPool[1]:Show()
- frame._iconPool[1].slotId = ns.Constants.TRINKET_SLOT_1
- frame._iconPool[2]:Show()
- frame._iconPool[2].itemId = 5001
- frame._iconPool[3]:Show()
- frame._iconPool[3].itemId = 5002
- ItemIcons.InnerFrame = frame
-
- inventoryCooldownBySlot[ns.Constants.TRINKET_SLOT_1] = { 10, 30, 1 }
- itemCooldownByID[5001] = { 20, 40, true }
- itemCooldownByID[5002] = { 0, 0, false }
-
- assert.is_true(ItemIcons:Refresh("test"))
- assert.same({ 10, 30 }, frame._iconPool[1].Cooldown.__cooldown)
- assert.same({ 20, 40 }, frame._iconPool[2].Cooldown.__cooldown)
- assert.is_true(frame._iconPool[3].Cooldown.__cleared)
- end)
-
- it("cleans up real module state on disable", function()
- local updateReasons = {}
- ItemIcons._viewerOriginalPoint = { "TOP", UIParent, "TOP", 0, 0 }
- ItemIcons._isEditModeActive = true
- function ItemIcons:UnregisterAllEvents()
- self._eventsUnregistered = true
- end
- function ItemIcons:UpdateLayout(reason)
- updateReasons[#updateReasons + 1] = reason
- return false
- end
-
- ItemIcons:OnDisable()
-
- assert.is_true(ItemIcons._eventsUnregistered)
- assert.same({ "OnDisable" }, updateReasons)
- assert.is_nil(ItemIcons._viewerOriginalPoint)
- assert.is_nil(ItemIcons._isEditModeActive)
- end)
-end)
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
new file mode 100644
index 00000000..241d6831
--- /dev/null
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -0,0 +1,448 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers = assert(
+ loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"),
+ "Unable to load Tests/TestHelpers.lua"
+)()
+
+--------------------------------------------------------------------------------
+-- Data Helpers (lightweight: only Constants needed)
+--------------------------------------------------------------------------------
+
+describe("ExtraIconsOptions data helpers", function()
+ local ExtraIconsOptions, ns
+
+ setup(function()
+ ns = {}
+ _G.Enum = {
+ PowerType = {
+ Mana = 0, Rage = 1, Focus = 2, Energy = 3, RunicPower = 6,
+ LunarPower = 8, Maelstrom = 11, Insanity = 13, Fury = 17,
+ ArcaneCharges = 16, Chi = 12, ComboPoints = 4, Essence = 19,
+ HolyPower = 9, SoulShards = 7,
+ },
+ }
+ _G.StaticPopupDialogs = _G.StaticPopupDialogs or {}
+ TestHelpers.LoadLiveConstants(ns)
+ ns.L = setmetatable({}, { __index = function(_, k) return k end })
+ ns.OptionUtil = {
+ GetIsDisabledDelegate = function() return function() return false end end,
+ CreateModuleEnabledHandler = function() return function() end end,
+ MakeConfirmDialog = function() return {} end,
+ }
+ ns.SettingsBuilder = { RegisterSection = function(_, _, section) ns.ExtraIconsOptions = section end }
+ TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
+ ExtraIconsOptions = ns.ExtraIconsOptions
+ end)
+
+ describe("_isStackKeyPresent", function()
+ it("finds stackKey in utility viewer", function()
+ local viewers = { utility = { { stackKey = "trinket1" } }, main = {} }
+ assert.is_true(ExtraIconsOptions._isStackKeyPresent(viewers, "trinket1"))
+ end)
+
+ it("finds stackKey in main viewer", function()
+ local viewers = { utility = {}, main = { { stackKey = "healthstones" } } }
+ assert.is_true(ExtraIconsOptions._isStackKeyPresent(viewers, "healthstones"))
+ end)
+
+ it("returns false when absent", function()
+ local viewers = { utility = { { stackKey = "trinket1" } }, main = {} }
+ assert.is_false(ExtraIconsOptions._isStackKeyPresent(viewers, "trinket2"))
+ end)
+ end)
+
+ describe("_isRacialPresent", function()
+ it("finds racial by spellId", function()
+ local viewers = { utility = { { kind = "spell", ids = { 59752 } } }, main = {} }
+ assert.is_true(ExtraIconsOptions._isRacialPresent(viewers, 59752))
+ end)
+
+ it("finds racial with table-style ids", function()
+ local viewers = { utility = { { kind = "spell", ids = { { spellId = 33697 } } } }, main = {} }
+ assert.is_true(ExtraIconsOptions._isRacialPresent(viewers, 33697))
+ end)
+
+ it("returns false when absent", function()
+ local viewers = { utility = { { kind = "spell", ids = { 59752 } } }, main = {} }
+ assert.is_false(ExtraIconsOptions._isRacialPresent(viewers, 33697))
+ end)
+
+ it("skips non-spell entries", function()
+ local viewers = { utility = { { stackKey = "trinket1" } }, main = {} }
+ assert.is_false(ExtraIconsOptions._isRacialPresent(viewers, 59752))
+ end)
+ end)
+
+ describe("_getEntryName", function()
+ local savedCSpell
+
+ before_each(function()
+ savedCSpell = _G.C_Spell
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ if spellId == 59752 then return "Every Man for Himself" end
+ return nil
+ end,
+ }
+ end)
+
+ after_each(function()
+ _G.C_Spell = savedCSpell
+ end)
+
+ it("returns builtin stack label", function()
+ assert.are.equal("Trinket 1", ExtraIconsOptions._getEntryName({ stackKey = "trinket1" }))
+ assert.are.equal("Combat Potions", ExtraIconsOptions._getEntryName({ stackKey = "combatPotions" }))
+ end)
+
+ it("returns spell name from API for racial spells", function()
+ assert.are.equal("Every Man for Himself",
+ ExtraIconsOptions._getEntryName({ kind = "spell", ids = { 59752 } }))
+ end)
+
+ it("falls back to spell ID when API returns nil", function()
+ assert.are.equal("Spell 12345",
+ ExtraIconsOptions._getEntryName({ kind = "spell", ids = { 12345 } }))
+ end)
+
+ it("returns generic item label", function()
+ assert.are.equal("Item 99999",
+ ExtraIconsOptions._getEntryName({ kind = "item", ids = { { itemID = 99999 } } }))
+ end)
+
+ it("returns Unknown for unrecognized entry", function()
+ assert.are.equal("Unknown", ExtraIconsOptions._getEntryName({}))
+ end)
+ end)
+
+ describe("_addStackKey", function()
+ it("appends to viewer", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+ ExtraIconsOptions._addStackKey(profile, "utility", "trinket1")
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal("trinket1", profile.extraIcons.viewers.utility[1].stackKey)
+ end)
+
+ it("creates viewer array if missing", function()
+ local profile = { extraIcons = { viewers = {} } }
+ ExtraIconsOptions._addStackKey(profile, "main", "healthstones")
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ end)
+ end)
+
+ describe("_addRacial", function()
+ it("adds spell entry with racial id", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+ ExtraIconsOptions._addRacial(profile, "utility", 59752)
+ local entry = profile.extraIcons.viewers.utility[1]
+ assert.are.equal("spell", entry.kind)
+ assert.are.same({ 59752 }, entry.ids)
+ end)
+ end)
+
+ describe("_addCustomEntry", function()
+ it("adds item entry with itemID wrappers", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+ ExtraIconsOptions._addCustomEntry(profile, "utility", "item", { 12345 })
+ local entry = profile.extraIcons.viewers.utility[1]
+ assert.are.equal("item", entry.kind)
+ assert.are.same({ { itemID = 12345 } }, entry.ids)
+ end)
+
+ it("adds spell entry with raw ids", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+ ExtraIconsOptions._addCustomEntry(profile, "main", "spell", { 100, 200 })
+ local entry = profile.extraIcons.viewers.main[1]
+ assert.are.equal("spell", entry.kind)
+ assert.are.same({ 100, 200 }, entry.ids)
+ end)
+ end)
+
+ describe("_removeEntry", function()
+ it("removes at given index", function()
+ local profile = { extraIcons = { viewers = { utility = {
+ { stackKey = "a" }, { stackKey = "b" }, { stackKey = "c" },
+ } } } }
+ ExtraIconsOptions._removeEntry(profile, "utility", 2)
+ assert.are.equal(2, #profile.extraIcons.viewers.utility)
+ assert.are.equal("a", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.are.equal("c", profile.extraIcons.viewers.utility[2].stackKey)
+ end)
+
+ it("is a no-op for out-of-range index", function()
+ local profile = { extraIcons = { viewers = { utility = { { stackKey = "a" } } } } }
+ ExtraIconsOptions._removeEntry(profile, "utility", 5)
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ end)
+ end)
+
+ describe("_reorderEntry", function()
+ it("swaps entry down", function()
+ local profile = { extraIcons = { viewers = { utility = {
+ { stackKey = "a" }, { stackKey = "b" },
+ } } } }
+ ExtraIconsOptions._reorderEntry(profile, "utility", 1, 1)
+ assert.are.equal("b", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.are.equal("a", profile.extraIcons.viewers.utility[2].stackKey)
+ end)
+
+ it("swaps entry up", function()
+ local profile = { extraIcons = { viewers = { utility = {
+ { stackKey = "a" }, { stackKey = "b" },
+ } } } }
+ ExtraIconsOptions._reorderEntry(profile, "utility", 2, -1)
+ assert.are.equal("b", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.are.equal("a", profile.extraIcons.viewers.utility[2].stackKey)
+ end)
+
+ it("is a no-op at boundary", function()
+ local profile = { extraIcons = { viewers = { utility = {
+ { stackKey = "a" }, { stackKey = "b" },
+ } } } }
+ ExtraIconsOptions._reorderEntry(profile, "utility", 1, -1)
+ assert.are.equal("a", profile.extraIcons.viewers.utility[1].stackKey)
+ end)
+ end)
+
+ describe("_moveEntry", function()
+ it("transfers entry to other viewer", function()
+ local profile = { extraIcons = { viewers = {
+ utility = { { stackKey = "a" }, { stackKey = "b" } },
+ main = { { stackKey = "c" } },
+ } } }
+ ExtraIconsOptions._moveEntry(profile, "utility", "main", 1)
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal("b", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.are.equal(2, #profile.extraIcons.viewers.main)
+ assert.are.equal("a", profile.extraIcons.viewers.main[2].stackKey)
+ end)
+
+ it("creates target array if missing", function()
+ local profile = { extraIcons = { viewers = { utility = { { stackKey = "a" } } } } }
+ ExtraIconsOptions._moveEntry(profile, "utility", "main", 1)
+ assert.are.equal(0, #profile.extraIcons.viewers.utility)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ end)
+
+ it("is a no-op for invalid index", function()
+ local profile = { extraIcons = { viewers = { utility = { { stackKey = "a" } }, main = {} } } }
+ ExtraIconsOptions._moveEntry(profile, "utility", "main", 5)
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+ end)
+ end)
+
+ describe("_parseIds", function()
+ it("parses single ID", function()
+ assert.are.same({ 12345 }, ExtraIconsOptions._parseIds("12345"))
+ end)
+
+ it("parses comma-separated IDs", function()
+ assert.are.same({ 100, 200, 300 }, ExtraIconsOptions._parseIds("100, 200, 300"))
+ end)
+
+ it("returns nil for empty string", function()
+ assert.is_nil(ExtraIconsOptions._parseIds(""))
+ end)
+
+ it("returns nil for nil", function()
+ assert.is_nil(ExtraIconsOptions._parseIds(nil))
+ end)
+
+ it("returns nil for non-numeric input", function()
+ assert.is_nil(ExtraIconsOptions._parseIds("abc"))
+ end)
+
+ it("returns nil for negative numbers", function()
+ assert.is_nil(ExtraIconsOptions._parseIds("-5"))
+ end)
+
+ it("returns nil for decimals", function()
+ assert.is_nil(ExtraIconsOptions._parseIds("1.5"))
+ end)
+
+ it("returns nil if any value is invalid", function()
+ assert.is_nil(ExtraIconsOptions._parseIds("100, abc, 200"))
+ end)
+ end)
+
+ describe("_otherViewer", function()
+ it("utility returns main", function()
+ assert.are.equal("main", ExtraIconsOptions._otherViewer("utility"))
+ end)
+
+ it("main returns utility", function()
+ assert.are.equal("utility", ExtraIconsOptions._otherViewer("main"))
+ end)
+ end)
+
+ describe("_getEntryIcon", function()
+ local savedTexture, savedCItem, savedCSpell
+
+ before_each(function()
+ savedTexture = _G.GetInventoryItemTexture
+ savedCItem = _G.C_Item
+ savedCSpell = _G.C_Spell
+ _G.GetInventoryItemTexture = function(_, slotId)
+ return slotId == 13 and "trinket1-tex" or nil
+ end
+ _G.C_Item = {
+ GetItemIconByID = function(itemId)
+ if itemId == 245898 then return "potion-tex" end
+ if itemId == 99999 then return "custom-item-tex" end
+ return nil
+ end,
+ }
+ _G.C_Spell = {
+ GetSpellTexture = function(spellId)
+ if spellId == 59752 then return "racial-tex" end
+ if spellId == 12345 then return "spell-tex" end
+ return nil
+ end,
+ }
+ end)
+
+ after_each(function()
+ _G.GetInventoryItemTexture = savedTexture
+ _G.C_Item = savedCItem
+ _G.C_Spell = savedCSpell
+ end)
+
+ it("returns equip slot texture for trinket stacks", function()
+ assert.are.equal("trinket1-tex",
+ ExtraIconsOptions._getEntryIcon({ stackKey = "trinket1" }))
+ end)
+
+ it("returns item icon for item stacks", function()
+ assert.are.equal("potion-tex",
+ ExtraIconsOptions._getEntryIcon({ stackKey = "combatPotions" }))
+ end)
+
+ it("returns spell texture for spell entries", function()
+ assert.are.equal("racial-tex",
+ ExtraIconsOptions._getEntryIcon({ kind = "spell", ids = { 59752 } }))
+ end)
+
+ it("returns spell texture for table-style spell ids", function()
+ assert.are.equal("spell-tex",
+ ExtraIconsOptions._getEntryIcon({ kind = "spell", ids = { { spellId = 12345 } } }))
+ end)
+
+ it("returns item icon for custom item entries", function()
+ assert.are.equal("custom-item-tex",
+ ExtraIconsOptions._getEntryIcon({ kind = "item", ids = { { itemID = 99999 } } }))
+ end)
+
+ it("returns nil for unknown entry", function()
+ assert.is_nil(ExtraIconsOptions._getEntryIcon({}))
+ end)
+
+ it("returns nil for unknown stackKey", function()
+ assert.is_nil(ExtraIconsOptions._getEntryIcon({ stackKey = "nonexistent" }))
+ end)
+ end)
+
+ describe("_isRacialForCurrentPlayer", function()
+ local savedUnitRace
+
+ before_each(function()
+ savedUnitRace = _G.UnitRace
+ end)
+
+ after_each(function()
+ _G.UnitRace = savedUnitRace
+ end)
+
+ it("returns true for non-spell entries", function()
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ assert.is_true(ExtraIconsOptions._isRacialForCurrentPlayer({ stackKey = "trinket1" }))
+ end)
+
+ it("returns true for current race's racial", function()
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ assert.is_true(ExtraIconsOptions._isRacialForCurrentPlayer({ kind = "spell", ids = { 59752 } }))
+ end)
+
+ it("returns false for another race's racial", function()
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ -- Orc racial (Blood Fury = 33697)
+ assert.is_false(ExtraIconsOptions._isRacialForCurrentPlayer({ kind = "spell", ids = { 33697 } }))
+ end)
+
+ it("returns true for non-racial spell entries", function()
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ assert.is_true(ExtraIconsOptions._isRacialForCurrentPlayer({ kind = "spell", ids = { 12345 } }))
+ end)
+
+ it("returns false for table-style racial ids from another race", function()
+ _G.UnitRace = function() return "Orc", "Orc", 2 end
+ -- Human racial (Every Man for Himself = 59752)
+ assert.is_false(ExtraIconsOptions._isRacialForCurrentPlayer(
+ { kind = "spell", ids = { { spellId = 59752 } } }))
+ end)
+
+ it("returns true when UnitRace returns unknown race", function()
+ _G.UnitRace = function() return "Unknown", "Unknown", 99 end
+ assert.is_true(ExtraIconsOptions._isRacialForCurrentPlayer({ kind = "spell", ids = { 33697 } }))
+ end)
+ end)
+end)
+
+--------------------------------------------------------------------------------
+-- Settings Page (full options environment)
+--------------------------------------------------------------------------------
+
+describe("ExtraIconsOptions settings page", function()
+ local originalGlobals
+ local profile, defaults, SB, ns
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ TestHelpers.SetupOptionsGlobals()
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ profile, defaults = TestHelpers.MakeOptionsProfile()
+ SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+
+ TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
+ ns.OptionsSections.ExtraIcons.RegisterSettings(SB)
+ end)
+
+ describe("canvas creation", function()
+ it("canvas is stored on ExtraIconsOptions", function()
+ local canvas = ns.ExtraIconsOptions._canvas
+ assert.is_not_nil(canvas)
+ end)
+
+ it("canvas exposes viewer state", function()
+ local canvas = ns.ExtraIconsOptions._canvas
+ assert.is_table(canvas._viewerRowPools)
+ assert.is_table(canvas._customForm)
+ assert.is_table(canvas._viewerHeaders)
+ assert.is_table(canvas._viewerEmptyLabels)
+ end)
+
+ it("canvas has enabled checkbox", function()
+ local canvas = ns.ExtraIconsOptions._canvas
+ assert.is_not_nil(canvas._enabledCheck)
+ end)
+
+ it("canvas has content region", function()
+ local canvas = ns.ExtraIconsOptions._canvas
+ assert.is_not_nil(canvas._contentRegion)
+ end)
+
+ it("creates a canvas subcategory", function()
+ assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
+ end)
+ end)
+end)
diff --git a/Tests/UI/ItemIconsOptions_spec.lua b/Tests/UI/ItemIconsOptions_spec.lua
deleted file mode 100644
index 5b3602b0..00000000
--- a/Tests/UI/ItemIconsOptions_spec.lua
+++ /dev/null
@@ -1,101 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local TestHelpers = assert(
- loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"),
- "Unable to load Tests/TestHelpers.lua"
-)()
-
-describe("ItemIconsOptions getters/setters/defaults", function()
- local originalGlobals
- local profile, defaults, SB, ns, settings
-
- setup(function()
- originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
- end)
-
- teardown(function()
- TestHelpers.RestoreGlobals(originalGlobals)
- end)
-
- before_each(function()
- TestHelpers.SetupOptionsGlobals()
- profile, defaults = TestHelpers.MakeOptionsProfile()
- SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
-
- settings = TestHelpers.CollectSettings(function()
- TestHelpers.LoadChunk("UI/ItemIconsOptions.lua", "ItemIconsOptions")(nil, ns)
- ns.OptionsSections.ItemIcons.RegisterSettings(SB)
- end)
- end)
-
- describe("enabled", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_enabled"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_enabled"]:SetValue(false)
- assert.is_false(profile.itemIcons.enabled)
- end)
- it("default matches expected", function()
- assert.is_true(settings["ECM_itemIcons_enabled"]._default)
- end)
- end)
-
- describe("showTrinket1", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_showTrinket1"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_showTrinket1"]:SetValue(false)
- assert.is_false(profile.itemIcons.showTrinket1)
- end)
- it("default matches expected", function()
- assert.is_true(settings["ECM_itemIcons_showTrinket1"]._default)
- end)
- end)
-
- describe("showTrinket2", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_showTrinket2"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_showTrinket2"]:SetValue(false)
- assert.is_false(profile.itemIcons.showTrinket2)
- end)
- end)
-
- describe("showHealthPotion", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_showHealthPotion"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_showHealthPotion"]:SetValue(false)
- assert.is_false(profile.itemIcons.showHealthPotion)
- end)
- end)
-
- describe("showCombatPotion", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_showCombatPotion"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_showCombatPotion"]:SetValue(false)
- assert.is_false(profile.itemIcons.showCombatPotion)
- end)
- end)
-
- describe("showHealthstone", function()
- it("getter returns profile value", function()
- assert.is_true(settings["ECM_itemIcons_showHealthstone"]:GetValue())
- end)
- it("setter writes to profile", function()
- settings["ECM_itemIcons_showHealthstone"]:SetValue(false)
- assert.is_false(profile.itemIcons.showHealthstone)
- end)
- it("default matches expected", function()
- assert.is_true(settings["ECM_itemIcons_showHealthstone"]._default)
- end)
- end)
-end)
diff --git a/Tests/UI/OptionsSections_spec.lua b/Tests/UI/OptionsSections_spec.lua
index 89f1ba51..36eeb31e 100644
--- a/Tests/UI/OptionsSections_spec.lua
+++ b/Tests/UI/OptionsSections_spec.lua
@@ -98,7 +98,7 @@ describe("Options sections and root assembly", function()
"ResourceBar",
"RuneBar",
"BuffBars",
- "ItemIcons",
+ "ExtraIcons",
"Profile",
"Advanced Options",
}) do
@@ -129,7 +129,7 @@ describe("Options sections and root assembly", function()
"ResourceBar",
"RuneBar",
"BuffBars",
- "ItemIcons",
+ "ExtraIcons",
"Profile",
"Advanced Options",
}, registerSettingsCalls)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
new file mode 100644
index 00000000..70bf2aa1
--- /dev/null
+++ b/UI/ExtraIconsOptions.lua
@@ -0,0 +1,650 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local C = ns.Constants
+local L = ns.L
+
+local BUILTIN_STACKS = C.BUILTIN_STACKS
+local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
+local RACIAL_ABILITIES = C.RACIAL_ABILITIES
+
+local ROW_HEIGHT = 26
+local ICON_SIZE = 20
+local BTN_SIZE = 22
+local CANVAS_MARGIN = 37
+local VIEWER_ORDER = { "utility", "main" }
+local VIEWER_LABELS = {
+ utility = "UTILITY_VIEWER_ICONS",
+ main = "MAIN_VIEWER_ICONS",
+}
+
+local ExtraIconsOptions = {}
+ns.ExtraIconsOptions = ExtraIconsOptions
+
+--------------------------------------------------------------------------------
+-- Data Helpers
+--------------------------------------------------------------------------------
+
+--- Check if a stackKey is present in any viewer's entries.
+function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if entry.stackKey == stackKey then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+--- Check if a racial spellId is present in any viewer's entries.
+function ExtraIconsOptions._isRacialPresent(viewers, spellId)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if entry.kind == "spell" and entry.ids then
+ for _, id in ipairs(entry.ids) do
+ local sid = type(id) == "table" and id.spellId or id
+ if sid == spellId then
+ return true
+ end
+ end
+ end
+ end
+ end
+ return false
+end
+
+--- Get display name for a config entry.
+function ExtraIconsOptions._getEntryName(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ return stack and stack.label or entry.stackKey
+ end
+ if entry.kind == "spell" and entry.ids then
+ local first = entry.ids[1]
+ local spellId = type(first) == "table" and first.spellId or first
+ local name = spellId and C_Spell.GetSpellName(spellId)
+ return name or ("Spell " .. tostring(spellId))
+ end
+ if entry.kind == "item" and entry.ids then
+ local first = entry.ids[1]
+ return "Item " .. tostring(type(first) == "table" and first.itemID or first)
+ end
+ return "Unknown"
+end
+
+--- Get display icon for a config entry.
+function ExtraIconsOptions._getEntryIcon(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then return nil end
+ if stack.kind == "equipSlot" then
+ return GetInventoryItemTexture("player", stack.slotId)
+ end
+ if stack.ids and stack.ids[1] then
+ local first = stack.ids[1]
+ local itemId = type(first) == "table" and first.itemID or first
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+ return nil
+ end
+ if entry.kind == "spell" and entry.ids then
+ local first = entry.ids[1]
+ local spellId = type(first) == "table" and first.spellId or first
+ return spellId and C_Spell.GetSpellTexture(spellId)
+ end
+ if entry.kind == "item" and entry.ids then
+ local first = entry.ids[1]
+ local itemId = type(first) == "table" and first.itemID or first
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+ return nil
+end
+
+--- Add a predefined stack entry to a viewer.
+function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+ local viewers = profile.extraIcons.viewers
+ viewers[viewerKey] = viewers[viewerKey] or {}
+ viewers[viewerKey][#viewers[viewerKey] + 1] = { stackKey = stackKey }
+end
+
+--- Add a racial spell entry to a viewer.
+function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ local viewers = profile.extraIcons.viewers
+ viewers[viewerKey] = viewers[viewerKey] or {}
+ viewers[viewerKey][#viewers[viewerKey] + 1] = { kind = "spell", ids = { spellId } }
+end
+
+--- Add a custom entry to a viewer.
+function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
+ local viewers = profile.extraIcons.viewers
+ viewers[viewerKey] = viewers[viewerKey] or {}
+ local entry = { kind = kind, ids = {} }
+ for _, id in ipairs(ids) do
+ if kind == "item" then
+ entry.ids[#entry.ids + 1] = { itemID = id }
+ else
+ entry.ids[#entry.ids + 1] = id
+ end
+ end
+ viewers[viewerKey][#viewers[viewerKey] + 1] = entry
+end
+
+--- Remove entry at index from a viewer.
+function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if entries and index >= 1 and index <= #entries then
+ table.remove(entries, index)
+ end
+end
+
+--- Swap entry with its neighbor (-1 = up, +1 = down).
+function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if not entries then return end
+ local target = index + direction
+ if target < 1 or target > #entries then return end
+ entries[index], entries[target] = entries[target], entries[index]
+end
+
+--- Move entry from one viewer to another (appends at end).
+function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
+ local from = profile.extraIcons.viewers[fromViewer]
+ if not from or index < 1 or index > #from then return end
+ local entry = table.remove(from, index)
+ local to = profile.extraIcons.viewers[toViewer] or {}
+ profile.extraIcons.viewers[toViewer] = to
+ to[#to + 1] = entry
+end
+
+--- Parse comma-separated numeric IDs from a string.
+--- Returns array of numbers, or nil if any value is invalid.
+function ExtraIconsOptions._parseIds(text)
+ if not text or text == "" then return nil end
+ local ids = {}
+ for part in text:gmatch("[^,]+") do
+ local trimmed = part:match("^%s*(.-)%s*$")
+ local num = tonumber(trimmed)
+ if not num or num <= 0 or num ~= math.floor(num) then
+ return nil
+ end
+ ids[#ids + 1] = num
+ end
+ return #ids > 0 and ids or nil
+end
+
+--- Get the opposite viewer key.
+function ExtraIconsOptions._otherViewer(viewerKey)
+ return viewerKey == "utility" and "main" or "utility"
+end
+
+--------------------------------------------------------------------------------
+-- UI: Tooltip helpers
+--------------------------------------------------------------------------------
+
+--- Set a simple text tooltip on a button.
+local function setButtonTooltip(btn, text)
+ btn:SetScript("OnEnter", function(self)
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ GameTooltip:SetText(text)
+ GameTooltip:Show()
+ end)
+ btn:SetScript("OnLeave", GameTooltip_Hide)
+end
+
+--- Set the entry-specific tooltip on a row's hit region.
+--- For spells: SetSpellByID; for items: SetItemByID; otherwise: text-only.
+local function setEntryTooltip(row, entry)
+ row:EnableMouse(true)
+ row:SetScript("OnEnter", function(self)
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if entry.kind == "spell" and entry.ids then
+ local first = entry.ids[1]
+ local spellId = type(first) == "table" and first.spellId or first
+ if spellId then
+ GameTooltip:SetSpellByID(spellId)
+ GameTooltip:Show()
+ return
+ end
+ end
+ if entry.kind == "item" and entry.ids then
+ local first = entry.ids[1]
+ local itemId = type(first) == "table" and first.itemID or first
+ if itemId then
+ GameTooltip:SetItemByID(itemId)
+ GameTooltip:Show()
+ return
+ end
+ end
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if stack and stack.kind == "equipSlot" then
+ GameTooltip:SetInventoryItem("player", stack.slotId)
+ GameTooltip:Show()
+ return
+ end
+ if stack and stack.ids and stack.ids[1] then
+ local first = stack.ids[1]
+ local itemId = type(first) == "table" and first.itemID or first
+ if itemId then
+ GameTooltip:SetItemByID(itemId)
+ GameTooltip:Show()
+ return
+ end
+ end
+ end
+ GameTooltip:SetText(ExtraIconsOptions._getEntryName(entry))
+ GameTooltip:Show()
+ end)
+ row:SetScript("OnLeave", GameTooltip_Hide)
+end
+
+--- Check if a racial entry belongs to the current player character.
+function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ if not (entry.kind == "spell" and entry.ids) then return true end
+ local _, raceFile = UnitRace("player")
+ local racial = raceFile and RACIAL_ABILITIES[raceFile]
+ if not racial then return true end
+ for _, racialEntry in pairs(RACIAL_ABILITIES) do
+ if racialEntry ~= racial then
+ for _, id in ipairs(entry.ids) do
+ local sid = type(id) == "table" and id.spellId or id
+ if sid == racialEntry.spellId then
+ return false
+ end
+ end
+ end
+ end
+ return true
+end
+
+--------------------------------------------------------------------------------
+-- UI: Entry Row Factory
+--------------------------------------------------------------------------------
+
+local function createEntryRow(parent)
+ local row = CreateFrame("Frame", nil, parent)
+ row:SetHeight(ROW_HEIGHT)
+
+ row._icon = row:CreateTexture(nil, "ARTWORK")
+ row._icon:SetSize(ICON_SIZE, ICON_SIZE)
+ row._icon:SetPoint("LEFT", 0, 0)
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetJustifyH("LEFT")
+ row._label:SetWordWrap(false)
+
+ row._deleteBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._deleteBtn:SetSize(BTN_SIZE, BTN_SIZE)
+ row._deleteBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
+ row._deleteBtn:SetText("x")
+
+ row._moveBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._moveBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
+ row._moveBtn:SetPoint("RIGHT", row._deleteBtn, "LEFT", -2, 0)
+
+ row._downBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._downBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
+ row._downBtn:SetPoint("RIGHT", row._moveBtn, "LEFT", -2, 0)
+ row._downBtn:SetText("v")
+
+ row._upBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._upBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
+ row._upBtn:SetPoint("RIGHT", row._downBtn, "LEFT", -2, 0)
+ row._upBtn:SetText("^")
+
+ row._label:SetPoint("RIGHT", row._upBtn, "LEFT", -6, 0)
+
+ return row
+end
+
+--------------------------------------------------------------------------------
+-- Canvas Layout Page
+--------------------------------------------------------------------------------
+
+StaticPopupDialogs["ECM_CONFIRM_RESET_EXTRA_ICONS"] =
+ ns.OptionUtil.MakeConfirmDialog(L["EXTRA_ICONS_RESET_CONFIRM"])
+
+local function createCanvasPage(SB)
+ local layout = SB.CreateCanvasLayout(L["EXTRA_ICONS"])
+ local frame = layout.frame
+
+ local viewerRowPools = { utility = {}, main = {} }
+ local viewerEmptyLabels = {}
+ local viewerHeaders = {}
+ local addBtnPool = {}
+
+ -- Header with Defaults button
+ local headerRow = layout:AddHeader(L["EXTRA_ICONS"])
+ local defaultsBtn = headerRow._defaultsButton
+ layout:AddSpacer(2)
+
+ -- Enabled checkbox — native layout (label left, control right)
+ local enabledRow = layout:_CreateRow()
+ layout:_AddLabel(enabledRow, L["ENABLE_EXTRA_ICONS"])
+ local enabledCheck = CreateFrame("CheckButton", nil, enabledRow, "UICheckButtonTemplate")
+ enabledCheck:SetSize(26, 26)
+ enabledCheck:SetPoint("LEFT", enabledRow, "CENTER", -80, 0)
+ frame._enabledCheck = enabledCheck
+
+ enabledCheck:SetScript("OnClick", function(self)
+ local enabled = self:GetChecked()
+ ns.Addon.db.profile.extraIcons.enabled = enabled
+ local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
+ handler(enabled)
+ frame:Refresh()
+ end)
+
+ layout:AddSpacer(6)
+
+ -- Content region (dims when disabled)
+ local contentRegion = CreateFrame("Frame", nil, frame)
+ contentRegion:SetPoint("TOPLEFT", 0, layout.yPos)
+ contentRegion:SetPoint("BOTTOMRIGHT")
+ frame._contentRegion = contentRegion
+
+ local contentY = 0
+
+ -- Predefined add buttons container
+ local addBtnContainer = CreateFrame("Frame", nil, contentRegion)
+ addBtnContainer:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, contentY)
+ addBtnContainer:SetPoint("RIGHT", contentRegion, "RIGHT", -20, 0)
+ addBtnContainer:SetHeight(28)
+ contentY = contentY - 36
+
+ -- Custom entry form — label on left, controls right of center
+ local formRow = CreateFrame("Frame", nil, contentRegion)
+ formRow:SetHeight(26)
+ formRow:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", 0, contentY)
+ formRow:SetPoint("RIGHT", contentRegion, "RIGHT", 0, 0)
+
+ local formLabel = formRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ formLabel:SetPoint("LEFT", CANVAS_MARGIN, 0)
+ formLabel:SetPoint("RIGHT", formRow, "CENTER", -85, 0)
+ formLabel:SetJustifyH("LEFT")
+ formLabel:SetText(L["ADD_CUSTOM_IDS"])
+
+ local editBox = CreateFrame("EditBox", nil, formRow, "InputBoxInstructionsTemplate")
+ editBox:SetSize(140, 22)
+ editBox:SetPoint("LEFT", formRow, "CENTER", -80, 0)
+ editBox:SetAutoFocus(false)
+ editBox.Instructions:SetText("1234, 5678")
+
+ local viewerToggle = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
+ viewerToggle:SetSize(60, 22)
+ viewerToggle:SetPoint("LEFT", editBox, "RIGHT", 4, 0)
+
+ local addSpellBtn = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
+ addSpellBtn:SetSize(72, 22)
+ addSpellBtn:SetPoint("LEFT", viewerToggle, "RIGHT", 4, 0)
+ addSpellBtn:SetText(L["ADD_SPELL"])
+
+ local addItemBtn = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
+ addItemBtn:SetSize(68, 22)
+ addItemBtn:SetPoint("LEFT", addSpellBtn, "RIGHT", 4, 0)
+ addItemBtn:SetText(L["ADD_ITEM"])
+
+ local customViewerKey = "utility"
+
+ frame._customForm = {
+ frame = formRow,
+ editBox = editBox,
+ viewerToggle = viewerToggle,
+ addSpellBtn = addSpellBtn,
+ addItemBtn = addItemBtn,
+ }
+
+ contentY = contentY - 36
+
+ -- Per-viewer headers and empty labels
+ for _, vk in ipairs(VIEWER_ORDER) do
+ viewerHeaders[vk] = contentRegion:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
+ viewerHeaders[vk]:SetJustifyH("LEFT")
+ viewerHeaders[vk]:SetText(L[VIEWER_LABELS[vk]])
+
+ viewerEmptyLabels[vk] = contentRegion:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ viewerEmptyLabels[vk]:SetJustifyH("LEFT")
+ viewerEmptyLabels[vk]:SetText(L["EXTRA_ICONS_NO_ENTRIES"])
+ end
+
+ -- Expose state for testing
+ frame._viewerRowPools = viewerRowPools
+ frame._viewerHeaders = viewerHeaders
+ frame._viewerEmptyLabels = viewerEmptyLabels
+ frame._addBtnContainer = addBtnContainer
+ frame._addBtnPool = addBtnPool
+
+ local function getProfile()
+ return ns.Addon.db.profile
+ end
+
+ local function scheduleUpdate()
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ end
+
+ local function resetToDefaults()
+ local viewers = getProfile().extraIcons.viewers
+ wipe(viewers)
+ viewers.utility = {}
+ for _, key in ipairs(BUILTIN_STACK_ORDER) do
+ viewers.utility[#viewers.utility + 1] = { stackKey = key }
+ end
+ viewers.main = {}
+ scheduleUpdate()
+ frame:Refresh()
+ end
+
+ -- Defaults button
+ defaultsBtn:SetText(SETTINGS_DEFAULTS)
+ defaultsBtn:SetScript("OnClick", function()
+ StaticPopup_Show("ECM_CONFIRM_RESET_EXTRA_ICONS", nil, nil, {
+ onAccept = resetToDefaults,
+ })
+ end)
+
+ -- Custom form: viewer toggle
+ local function updateViewerToggle()
+ viewerToggle:SetText(customViewerKey == "utility" and "Utility" or "Main")
+ end
+ updateViewerToggle()
+ viewerToggle:SetScript("OnClick", function()
+ customViewerKey = customViewerKey == "utility" and "main" or "utility"
+ updateViewerToggle()
+ end)
+
+ -- Custom form: add handlers
+ local function addCustom(kind)
+ local text = editBox:GetText()
+ local ids = ExtraIconsOptions._parseIds(text)
+ if not ids then return end
+ ExtraIconsOptions._addCustomEntry(getProfile(), customViewerKey, kind, ids)
+ editBox:SetText("")
+ scheduleUpdate()
+ frame:Refresh()
+ end
+ addSpellBtn:SetScript("OnClick", function() addCustom("spell") end)
+ addItemBtn:SetScript("OnClick", function() addCustom("item") end)
+
+ function frame:Refresh()
+ local profile = getProfile()
+ local viewers = profile.extraIcons.viewers
+ local enabled = profile.extraIcons.enabled
+ enabledCheck:SetChecked(enabled)
+ contentRegion:SetAlpha(enabled and 1 or 0.5)
+
+ -- Hide all add buttons
+ for _, btn in ipairs(addBtnPool) do
+ btn:Hide()
+ end
+
+ -- Add predefined stack buttons
+ local btnIndex = 0
+ local xOffset = 0
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ btnIndex = btnIndex + 1
+ local btn = addBtnPool[btnIndex]
+ if not btn then
+ btn = CreateFrame("Button", nil, addBtnContainer, "UIPanelButtonTemplate")
+ btn:SetHeight(24)
+ addBtnPool[btnIndex] = btn
+ end
+ local stack = BUILTIN_STACKS[stackKey]
+ local key = stackKey
+ btn:SetText(stack.label)
+ btn:SetWidth(84)
+ btn:ClearAllPoints()
+ btn:SetPoint("LEFT", xOffset, 0)
+ btn:SetScript("OnClick", function()
+ ExtraIconsOptions._addStackKey(getProfile(), "utility", key)
+ scheduleUpdate()
+ frame:Refresh()
+ end)
+ btn:Show()
+ xOffset = xOffset + 90
+ end
+ end
+
+ -- Racial button
+ local _, raceFile = UnitRace("player")
+ local racial = raceFile and RACIAL_ABILITIES[raceFile]
+ if racial and not ExtraIconsOptions._isRacialPresent(viewers, racial.spellId) then
+ btnIndex = btnIndex + 1
+ local btn = addBtnPool[btnIndex]
+ if not btn then
+ btn = CreateFrame("Button", nil, addBtnContainer, "UIPanelButtonTemplate")
+ btn:SetHeight(24)
+ addBtnPool[btnIndex] = btn
+ end
+ local capturedSpellId = racial.spellId
+ local racialName = C_Spell.GetSpellName(capturedSpellId) or "Racial"
+ btn:SetText(L["ADD_RACIAL"]:format(racialName))
+ btn:SetWidth(btn:GetTextWidth() + 20)
+ btn:ClearAllPoints()
+ btn:SetPoint("LEFT", xOffset, 0)
+ btn:SetScript("OnClick", function()
+ ExtraIconsOptions._addRacial(getProfile(), "utility", capturedSpellId)
+ scheduleUpdate()
+ frame:Refresh()
+ end)
+ btn:Show()
+ end
+
+ -- Viewer lists
+ local y = contentY
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ viewerHeaders[viewerKey]:ClearAllPoints()
+ viewerHeaders[viewerKey]:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, y)
+ y = y - 18
+
+ local pool = viewerRowPools[viewerKey]
+ local entries = viewers[viewerKey] or {}
+
+ -- Filter out racials not for this character
+ local visibleEntries = {}
+ for i, entry in ipairs(entries) do
+ if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
+ visibleEntries[#visibleEntries + 1] = { index = i, entry = entry }
+ end
+ end
+
+ for _, row in ipairs(pool) do
+ row:Hide()
+ end
+
+ if #visibleEntries == 0 then
+ viewerEmptyLabels[viewerKey]:ClearAllPoints()
+ viewerEmptyLabels[viewerKey]:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN + 8, y)
+ viewerEmptyLabels[viewerKey]:Show()
+ y = y - ROW_HEIGHT
+ else
+ viewerEmptyLabels[viewerKey]:Hide()
+ end
+
+ for vi, vis in ipairs(visibleEntries) do
+ local entry = vis.entry
+ local ci = vis.index
+ local row = pool[vi]
+ if not row then
+ row = createEntryRow(contentRegion)
+ pool[vi] = row
+ end
+
+ local entryName = ExtraIconsOptions._getEntryName(entry)
+ row._label:SetText(entryName)
+ local icon = ExtraIconsOptions._getEntryIcon(entry)
+ row._icon:SetTexture(icon or 134400)
+ row._upBtn:SetEnabled(ci > 1)
+ row._downBtn:SetEnabled(ci < #entries)
+
+ local other = ExtraIconsOptions._otherViewer(viewerKey)
+
+ -- Entry tooltip on hover
+ setEntryTooltip(row, entry)
+
+ -- Button tooltips
+ setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
+ setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
+ setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(other))
+ setButtonTooltip(row._deleteBtn, L["REMOVE_TOOLTIP"])
+
+ row._upBtn:SetScript("OnClick", function()
+ ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, -1)
+ scheduleUpdate()
+ frame:Refresh()
+ end)
+ row._downBtn:SetScript("OnClick", function()
+ ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, 1)
+ scheduleUpdate()
+ frame:Refresh()
+ end)
+ row._moveBtn:SetText(viewerKey == "utility" and ">" or "<")
+ row._moveBtn:SetScript("OnClick", function()
+ ExtraIconsOptions._moveEntry(getProfile(), viewerKey, other, ci)
+ scheduleUpdate()
+ frame:Refresh()
+ end)
+ row._deleteBtn:SetScript("OnClick", function()
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
+ onAccept = function()
+ ExtraIconsOptions._removeEntry(getProfile(), viewerKey, ci)
+ scheduleUpdate()
+ frame:Refresh()
+ end,
+ })
+ end)
+
+ row:ClearAllPoints()
+ row:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, y)
+ row:SetPoint("RIGHT", contentRegion, "RIGHT", -20, 0)
+ row:Show()
+ y = y - ROW_HEIGHT
+ end
+
+ y = y - 12
+ end
+ end
+
+ frame:SetScript("OnShow", function(self)
+ self:Refresh()
+ end)
+
+ return frame
+end
+
+--------------------------------------------------------------------------------
+-- Settings Registration
+--------------------------------------------------------------------------------
+
+StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
+ ns.OptionUtil.MakeConfirmDialog(L["REMOVE_ENTRY_CONFIRM"])
+
+function ExtraIconsOptions.RegisterSettings(SB)
+ local canvasFrame = createCanvasPage(SB)
+ ExtraIconsOptions._canvas = canvasFrame
+end
+
+ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
diff --git a/UI/ItemIconsOptions.lua b/UI/ItemIconsOptions.lua
deleted file mode 100644
index 5bb16ed1..00000000
--- a/UI/ItemIconsOptions.lua
+++ /dev/null
@@ -1,76 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local _, ns = ...
-local L = ns.L
-
-local ItemIconsOptions = {}
-local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("itemIcons")
-
-function ItemIconsOptions.RegisterSettings(SB)
- SB.RegisterFromTable({
- name = L["ITEM_ICONS"],
- path = "itemIcons",
- args = {
- enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_ITEM_ICONS"],
- desc = L["ENABLE_ITEM_ICONS_DESC"],
- order = 0,
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("ItemIcons"),
- },
- equipmentHeader = {
- type = "header",
- name = L["EQUIPMENT"],
- disabled = isDisabled,
- order = 10,
- },
- showTrinket1 = {
- type = "toggle",
- path = "showTrinket1",
- name = L["SHOW_FIRST_TRINKET"],
- desc = L["SHOW_FIRST_TRINKET_DESC"],
- disabled = isDisabled,
- order = 11,
- },
- showTrinket2 = {
- type = "toggle",
- path = "showTrinket2",
- name = L["SHOW_SECOND_TRINKET"],
- disabled = isDisabled,
- order = 12,
- },
- consumablesHeader = {
- type = "header",
- name = L["CONSUMABLES"],
- disabled = isDisabled,
- order = 20,
- },
- showHealthPotion = {
- type = "toggle",
- path = "showHealthPotion",
- name = L["SHOW_HEALTH_POTIONS"],
- disabled = isDisabled,
- order = 21,
- },
- showCombatPotion = {
- type = "toggle",
- path = "showCombatPotion",
- name = L["SHOW_COMBAT_POTIONS"],
- disabled = isDisabled,
- order = 22,
- },
- showHealthstone = {
- type = "toggle",
- path = "showHealthstone",
- name = L["SHOW_HEALTHSTONE"],
- disabled = isDisabled,
- order = 23,
- },
- },
- })
-end
-
-ns.SettingsBuilder.RegisterSection(ns, "ItemIcons", ItemIconsOptions)
diff --git a/UI/Options.lua b/UI/Options.lua
index b892ab12..749bd27a 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -218,7 +218,7 @@ function Options:OnInitialize()
"ResourceBar",
"RuneBar",
"BuffBars",
- "ItemIcons",
+ "ExtraIcons",
"Profile",
"Advanced Options",
}
From af966efc2dbce7155fb0646ae8d7f7de42652a9c Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 7 Apr 2026 21:46:56 +1000
Subject: [PATCH 02/53] Fix the problems with font and texture settings.
---
ARCHITECTURE.md | 4 +-
BarMixin.lua | 20 +-
.../LibSettingsBuilder/LibSettingsBuilder.lua | 39 +-
.../Tests/LibSettingsBuilder_spec.lua | 60 +++
Locales/en.lua | 10 +-
Modules/ExtraIcons.lua | 111 +++-
Runtime.lua | 10 +-
Tests/ECM_Runtime_spec.lua | 23 +
Tests/FrameMixin_spec.lua | 24 +
Tests/Modules/ExtraIcons_spec.lua | 85 +++
Tests/TestHelpers.lua | 40 +-
Tests/UI/ExtraIconsOptions_spec.lua | 87 ++-
UI/ExtraIconsOptions.lua | 494 ++++++++----------
13 files changed, 673 insertions(+), 334 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 44795def..7159094b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -353,7 +353,7 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Uses a dual-viewer architecture with a stack-aware resolver.
-**Viewer Registry:** Maps abstract viewer keys to Blizzard frame globals. Current keys: `"utility"` → `UtilityCooldownViewer`, `"main"` → `EssentialCooldownViewer`. Each viewer has its own container frame, on-demand icon pool, centering offset, and hook set.
+**Viewer Registry:** Maps abstract viewer keys to Blizzard frame globals. Current keys: `"utility"` → `UtilityCooldownViewer`, `"main"` → `EssentialCooldownViewer`. Each viewer has its own container frame, on-demand icon pool, and hook set. The main viewer's expanded footprint also drives a shared midpoint offset for the two-viewer layout; utility applies that pair offset first, then layers its own local centering so moving icons between viewers preserves the combined layout.
**Entry Kinds and Resolution:**
@@ -382,7 +382,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and embeds a canvas frame for viewer management. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Add buttons for absent predefined stacks and racials; per-row controls for reorder (↑↓), move between viewers (→←), and delete (✕); custom entry form with ID parsing.
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Quick-add buttons register absent predefined stacks and the current racial; per-row controls handle reorder (↑↓), move between viewers (→←), and delete (✕).
### FrameUtil (`ns.FrameUtil`)
diff --git a/BarMixin.lua b/BarMixin.lua
index 2e48618d..2789a617 100644
--- a/BarMixin.lua
+++ b/BarMixin.lua
@@ -123,6 +123,24 @@ end)
local FrameProto = {}
+--- Returns the effective root anchor for chained modules.
+--- When ExtraIcons extends the main viewer with additional icons, the chain
+--- should anchor to the combined visual width rather than the Blizzard frame
+--- alone so attached modules inherit the widened footprint.
+---@return Frame
+local function getPrimaryChainAnchor()
+ local addon = ns.Addon
+ local extraIcons = addon and addon.GetECMModule and addon:GetECMModule(C.EXTRAICONS, true)
+ if extraIcons and extraIcons.IsEnabled and extraIcons:IsEnabled() and extraIcons.GetMainViewerAnchor then
+ local anchor = extraIcons:GetMainViewerAnchor()
+ if anchor then
+ return anchor
+ end
+ end
+
+ return _G["EssentialCooldownViewer"] or UIParent
+end
+
--- Determine the correct anchor for this specific frame in the fixed order.
--- @param frameName string|nil The name of the current frame, or nil if first in chain.
--- @param anchorMode string|nil The anchor mode to filter by (defaults to ANCHORMODE_CHAIN).
@@ -161,7 +179,7 @@ function FrameProto:GetNextChainAnchor(frameName, anchorMode)
return ns.Runtime.DetachedAnchor or UIParent, true
end
- return _G["EssentialCooldownViewer"] or UIParent, true
+ return getPrimaryChainAnchor(), true
end
function FrameProto:SetHidden(hide)
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index 5df2377a..8e835a8d 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -155,6 +155,40 @@ local function resetListElement(frame)
end
end
+local function hideListElementChildren(frame)
+ if not frame or not frame.GetChildren then
+ return
+ end
+
+ local children = { frame:GetChildren() }
+ for i = 1, #children do
+ local child = children[i]
+ if child and child.Hide then
+ child:Hide()
+ end
+ end
+end
+
+local function hideListElementRegions(frame)
+ if not frame or not frame.GetRegions then
+ return
+ end
+
+ local regions = { frame:GetRegions() }
+ for i = 1, #regions do
+ local region = regions[i]
+ if region and region.Hide then
+ region:Hide()
+ end
+ end
+end
+
+local function resetPlainListElementFrame(frame)
+ hideListElementChildren(frame)
+ hideListElementRegions(frame)
+ resetListElement(frame)
+end
+
local function ensureSubheaderTitle(frame)
if frame._lsbSubheaderTitle then
return frame._lsbSubheaderTitle
@@ -260,7 +294,7 @@ local function createCustomListRowInitializer(template, data, extent, initFrame)
frame.NewFeature:Hide()
end
- resetListElement(frame)
+ resetPlainListElementFrame(frame)
initFrame(frame, self.data, self)
if not frame._lsbHasCustomEvaluateState then
@@ -288,9 +322,10 @@ local function createCustomListRowInitializer(template, data, extent, initFrame)
end
if frame._lsbCanvas then
frame._lsbCanvas:Hide()
+ frame._lsbCanvas = nil
end
- resetListElement(frame)
+ resetPlainListElementFrame(frame)
frame.data = nil
frame._lsbInitializer = nil
end
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index c2a59a3e..f25bcc63 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -598,6 +598,66 @@ describe("LibSettingsBuilder", function()
assert.are.equal(120, canvas:GetHeight())
end)
+ it("plain list rows hide recycled custom children and preview regions", function()
+ local function makeListElementFrame()
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ fontString.SetJustifyH = function() end
+ fontString.SetJustifyV = function() end
+ return fontString
+ end
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+ return frame
+ end
+
+ local subheaderFrame = makeListElementFrame()
+ local subheaderForeignChild = createScriptableFrame()
+ local subheaderForeignRegion = createScriptableFrame()
+ subheaderFrame.GetChildren = function()
+ return subheaderForeignChild
+ end
+ subheaderFrame.GetRegions = function()
+ return subheaderForeignRegion
+ end
+
+ local subheader = SB.Subheader({ name = "Item Quality" })
+ subheader:InitFrame(subheaderFrame)
+
+ assert.is_false(subheaderForeignChild:IsShown())
+ assert.is_false(subheaderForeignRegion:IsShown())
+
+ local canvas = createScriptableFrame()
+ canvas.SetParent = function(self, parent)
+ self._parent = parent
+ end
+ canvas.GetParent = function(self)
+ return self._parent
+ end
+
+ local embedFrame = makeListElementFrame()
+ local embedForeignChild = createScriptableFrame()
+ local embedForeignRegion = createScriptableFrame()
+ embedFrame.GetChildren = function()
+ return embedForeignChild
+ end
+ embedFrame.GetRegions = function()
+ return embedForeignRegion
+ end
+
+ local embed = SB.EmbedCanvas(canvas, 120)
+ embed:InitFrame(embedFrame)
+
+ assert.is_false(embedForeignChild:IsShown())
+ assert.is_false(embedForeignRegion:IsShown())
+ assert.are.equal(embedFrame, canvas:GetParent())
+ end)
+
-- Button
it("Button creates button initializer with onClick", function()
local clicked = false
diff --git a/Locales/en.lua b/Locales/en.lua
index ff1ae88c..06617337 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -228,10 +228,14 @@ L["ENABLE_EXTRA_ICONS_DESC"] =
L["UTILITY_VIEWER_ICONS"] = "Utility Viewer Icons"
L["MAIN_VIEWER_ICONS"] = "Main Viewer Icons"
L["ADD_RACIAL"] = "Add %s"
-L["ADD_ITEM"] = "Add Item"
-L["ADD_SPELL"] = "Add Spell"
+L["ADD_ITEM"] = "Item"
+L["ADD_SPELL"] = "Spell"
L["EXTRA_ICONS_RESET_CONFIRM"] = "Reset extra icons to defaults?"
-L["ADD_CUSTOM_IDS"] = "Spell or Item IDs (comma-separated)"
+L["ADD_NEW_HEADER"] = "Add New"
+L["ENTRY_TYPE"] = "Type"
+L["ENTRY_VIEWER"] = "Viewer"
+L["ADD_ENTRY"] = "Add"
+L["PRESETS_HEADER"] = "Quick Add"
L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
L["REMOVE_ENTRY_CONFIRM"] = "Remove %s?"
L["MOVE_UP_TOOLTIP"] = "Move up"
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 75ff3132..569777ff 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -30,6 +30,27 @@ local VIEWER_REGISTRY = {
main = { blizzFrameKey = "EssentialCooldownViewer" },
}
+local VIEWER_ORDER = { "main", "utility" }
+
+local function cacheOriginalPoint(viewerState, blizzFrame)
+ if viewerState.originalPoint or not blizzFrame then
+ return
+ end
+
+ local point, relativeTo, relativePoint, x, y = blizzFrame:GetPoint()
+ viewerState.originalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
+end
+
+local function applyViewerPoint(viewerState, blizzFrame, offsetX)
+ local point = viewerState and viewerState.originalPoint
+ if not point or not blizzFrame then
+ return
+ end
+
+ blizzFrame:ClearAllPoints()
+ blizzFrame:SetPoint(point[1], point[2], point[3], point[4] + (offsetX or 0), point[5])
+end
+
--------------------------------------------------------------------------------
-- Resolver Functions
--------------------------------------------------------------------------------
@@ -295,7 +316,14 @@ function ExtraIcons:CreateFrame()
local container = CreateFrame("Frame", "ECMExtraIcons_" .. viewerKey, parent)
container:SetFrameStrata("MEDIUM")
container:SetSize(1, 1)
+
+ local anchorFrame = CreateFrame("Frame", "ECMExtraIcons_" .. viewerKey .. "Anchor", parent)
+ anchorFrame:SetFrameStrata("MEDIUM")
+ anchorFrame:SetSize(1, 1)
+ anchorFrame:Hide()
+
self._viewers[viewerKey] = {
+ anchorFrame = anchorFrame,
container = container,
iconPool = {},
originalPoint = nil,
@@ -321,19 +349,63 @@ function ExtraIcons:ShouldShow()
return false
end
+--- Updates a viewer's logical anchor frame.
+--- The main viewer uses this so chained ECM modules can inherit the combined
+--- width of Blizzard icons plus any appended extra icons.
+---@param viewerKey string
+---@param blizzFrame Frame|nil
+---@param rightFrame Frame|nil
+function ExtraIcons:_updateViewerAnchor(viewerKey, blizzFrame, rightFrame)
+ local vs = self._viewers and self._viewers[viewerKey]
+ local anchorFrame = vs and vs.anchorFrame
+ if not anchorFrame then
+ return
+ end
+
+ if not blizzFrame or not blizzFrame:IsShown() then
+ anchorFrame:Hide()
+ return
+ end
+
+ local rightAnchor = rightFrame and rightFrame:IsShown() and rightFrame or blizzFrame
+
+ anchorFrame:ClearAllPoints()
+ anchorFrame:SetPoint("LEFT", blizzFrame, "LEFT", 0, 0)
+ anchorFrame:SetPoint("RIGHT", rightAnchor, "RIGHT", 0, 0)
+ anchorFrame:SetPoint("TOP", blizzFrame, "TOP", 0, 0)
+ anchorFrame:SetPoint("BOTTOM", blizzFrame, "BOTTOM", 0, 0)
+ anchorFrame:Show()
+end
+
+--- Gets the effective chain anchor for the main viewer.
+---@return Frame|nil
+function ExtraIcons:GetMainViewerAnchor()
+ local vs = self._viewers and self._viewers.main
+ local anchorFrame = vs and vs.anchorFrame
+ if anchorFrame and anchorFrame:IsShown() then
+ return anchorFrame
+ end
+
+ return _G[VIEWER_REGISTRY.main.blizzFrameKey]
+end
+
--- Lays out icons for a single viewer.
---@param viewerKey string The viewer key ("utility" or "main").
---@param entries table[] The config entries for this viewer.
---@param isEditing boolean Whether edit mode is active.
+---@param sharedOffsetX number|nil Pair-wide midpoint offset inherited from the main viewer.
---@return boolean changed Whether any icons were placed.
-function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
+---@return number offsetX Local centering offset contributed by this viewer's own footprint.
+function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
local reg = VIEWER_REGISTRY[viewerKey]
local blizzFrame = _G[reg.blizzFrameKey]
local vs = self._viewers[viewerKey]
if not vs then
- return false
+ return false, 0
end
local container = vs.container
+ sharedOffsetX = sharedOffsetX or 0
+ cacheOriginalPoint(vs, blizzFrame)
-- Resolve entries to displayable items
local items
@@ -345,16 +417,15 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
if #items == 0 then
-- Restore viewer position and hide container
- local p = vs.originalPoint
- if p and blizzFrame then
- blizzFrame:ClearAllPoints()
- blizzFrame:SetPoint(p[1], p[2], p[3], p[4], p[5])
- end
+ applyViewerPoint(vs, blizzFrame, sharedOffsetX)
if isEditing then
vs.originalPoint = nil
end
container:Hide()
- return false
+ if viewerKey == "main" then
+ self:_updateViewerAnchor(viewerKey, blizzFrame, nil)
+ end
+ return false, 0
end
-- Hide all existing pool icons
@@ -409,10 +480,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
local viewerWidth = numActiveViewerIcons > 0 and blizzFrame:GetWidth() or 0
local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
-
- local p = vs.originalPoint
- blizzFrame:ClearAllPoints()
- blizzFrame:SetPoint(p[1], p[2], p[3], p[4] + viewerOffsetX, p[5])
+ applyViewerPoint(vs, blizzFrame, viewerOffsetX + sharedOffsetX)
-- Position and configure each icon
local borderScale = ns.Constants.EXTRA_ICON_BORDER_SCALE
@@ -445,7 +513,11 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
container:SetPoint("LEFT", lastActiveItemFrame or blizzFrame, "RIGHT", spacing, 0)
container:Show()
- return true
+ if viewerKey == "main" then
+ self:_updateViewerAnchor(viewerKey, blizzFrame, container)
+ end
+
+ return true, viewerOffsetX
end
--- Override UpdateLayout to position icons for all viewers.
@@ -469,12 +541,18 @@ function ExtraIcons:UpdateLayout(why)
-- the extra-icon containers.
local viewers = shouldShow and moduleConfig and moduleConfig.viewers
local anyPlaced = false
+ local sharedOffsetX = 0
- for viewerKey in pairs(VIEWER_REGISTRY) do
+ for i = 1, #VIEWER_ORDER do
+ local viewerKey = VIEWER_ORDER[i]
local entries = viewers and viewers[viewerKey] or {}
- if self:_updateSingleViewer(viewerKey, entries, isEditing) then
+ local changed, localOffsetX = self:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
+ if changed then
anyPlaced = true
end
+ if viewerKey == "main" then
+ sharedOffsetX = localOffsetX or 0
+ end
end
-- Manage InnerFrame visibility (not handled by ApplyFramePosition since
@@ -625,6 +703,9 @@ function ExtraIcons:_hookViewer(viewerKey)
if vs.container then
vs.container:Hide()
end
+ if vs.anchorFrame then
+ vs.anchorFrame:Hide()
+ end
if self:IsEnabled() then
ns.Runtime.RequestLayout("ExtraIcons:OnHide")
end
diff --git a/Runtime.lua b/Runtime.lua
index 9d4adca0..70f8ae58 100644
--- a/Runtime.lua
+++ b/Runtime.lua
@@ -355,6 +355,14 @@ local function updateAllLayouts(reason)
invalidateDetachedAnchorMetrics()
updateDetachedAnchorLayout()
+ -- ExtraIcons can widen the main viewer's effective footprint. Update it
+ -- before chained modules so attached bars compute width-dependent layout
+ -- (ticks, fragments, etc.) against the final combined anchor.
+ local extraIcons = _modules[C.EXTRAICONS]
+ if extraIcons and extraIcons:IsReady() then
+ extraIcons:UpdateLayout(reason)
+ end
+
-- Chain frames update in deterministic order so downstream bars can
-- resolve anchors against already-laid-out predecessors.
for _, moduleName in ipairs(C.CHAIN_ORDER) do
@@ -365,7 +373,7 @@ local function updateAllLayouts(reason)
end
for frameName, module in pairs(_modules) do
- if not _chainSet[frameName] and module:IsReady() then
+ if frameName ~= C.EXTRAICONS and not _chainSet[frameName] and module:IsReady() then
module:UpdateLayout(reason)
end
end
diff --git a/Tests/ECM_Runtime_spec.lua b/Tests/ECM_Runtime_spec.lua
index 53353a6f..4f24aa64 100644
--- a/Tests/ECM_Runtime_spec.lua
+++ b/Tests/ECM_Runtime_spec.lua
@@ -511,6 +511,29 @@ describe("ECM.Runtime layout system", function()
assert.same({ "First" }, reasons)
end)
+ it("updates ExtraIcons before the chain so attached bars see the final viewer footprint", function()
+ local callOrder = {}
+ local extraIcons = makeRegisteredModule(ns.Constants.EXTRAICONS)
+ local powerBar = makeRegisteredModule(ns.Constants.POWERBAR)
+
+ _G._testDB.profile.extraIcons = { enabled = true }
+ _G._testDB.profile.powerBar.anchorMode = ns.Constants.ANCHORMODE_CHAIN
+
+ extraIcons.UpdateLayout = function(_, reason)
+ callOrder[#callOrder + 1] = "ExtraIcons:" .. reason
+ end
+ powerBar.UpdateLayout = function(_, reason)
+ callOrder[#callOrder + 1] = "PowerBar:" .. reason
+ end
+
+ ns.Runtime.ScheduleLayoutUpdate(0, "Order")
+
+ assert.are.equal(1, #timerQueue)
+ timerQueue[1].callback()
+
+ assert.same({ "ExtraIcons:Order", "PowerBar:Order" }, callOrder)
+ end)
+
it("zero-delay layout events update visibility immediately without adding a runtime timer hop", function()
local mod = makeRegisteredModule()
local reasons = {}
diff --git a/Tests/FrameMixin_spec.lua b/Tests/FrameMixin_spec.lua
index a00dcf3e..dc3137b8 100644
--- a/Tests/FrameMixin_spec.lua
+++ b/Tests/FrameMixin_spec.lua
@@ -340,6 +340,30 @@ describe("FrameMixin real source", function()
assert.is_true(isFirst)
end)
+ it("GetNextChainAnchor prefers the ExtraIcons main anchor when available", function()
+ local extraAnchor = makeFrame({ name = "ECMExtraIcons_mainAnchor" })
+
+ ns.Addon.GetECMModule = function(_, name)
+ if name == ns.Constants.EXTRAICONS then
+ return {
+ IsEnabled = function()
+ return true
+ end,
+ GetMainViewerAnchor = function()
+ return extraAnchor
+ end,
+ }
+ end
+
+ return nil
+ end
+
+ local anchor, isFirst = FrameProto.GetNextChainAnchor({ Name = "TestModule" }, ns.Constants.CHAIN_ORDER[1])
+
+ assert.are.equal(extraAnchor, anchor)
+ assert.is_true(isFirst)
+ end)
+
it("CalculateLayoutParams keeps the first chained module anchored below the viewer", function()
ns.Addon.GetECMModule = function()
return nil
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 6b466a25..8d31fbe9 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -771,6 +771,91 @@ describe("ExtraIcons real source", function()
assert.are.equal(87, x)
end)
+ it("publishes a combined main-viewer anchor when main extra icons are shown", function()
+ local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
+ activeFrame.isActive = true
+ EssentialCooldownViewer.childXPadding = 4
+ EssentialCooldownViewer.iconScale = 1.0
+ EssentialCooldownViewer:SetWidth(46)
+ EssentialCooldownViewer.GetItemFrames = function()
+ return { activeFrame }
+ end
+ EssentialCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({}, { { stackKey = "trinket1" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+
+ local vs = ExtraIcons._viewers.main
+ local anchor = ExtraIcons:GetMainViewerAnchor()
+ assert.are.equal(vs.anchorFrame, anchor)
+ assert.is_true(anchor:IsShown())
+ assert.same({
+ { "LEFT", EssentialCooldownViewer, "LEFT", 0, 0 },
+ { "RIGHT", vs.container, "RIGHT", 0, 0 },
+ { "TOP", EssentialCooldownViewer, "TOP", 0, 0 },
+ { "BOTTOM", EssentialCooldownViewer, "BOTTOM", 0, 0 },
+ }, anchor.__anchors)
+ end)
+
+ it("keeps utility aligned to the shared midpoint when an icon moves to main", function()
+ local utilityActiveFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
+ utilityActiveFrame.isActive = true
+ UtilityCooldownViewer.childXPadding = 4
+ UtilityCooldownViewer.iconScale = 1.0
+ UtilityCooldownViewer:SetWidth(22)
+ UtilityCooldownViewer.GetItemFrames = function()
+ return { utilityActiveFrame }
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ local mainActiveFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
+ mainActiveFrame.isActive = true
+ EssentialCooldownViewer.childXPadding = 4
+ EssentialCooldownViewer.iconScale = 1.0
+ EssentialCooldownViewer:SetWidth(22)
+ EssentialCooldownViewer.GetItemFrames = function()
+ return { mainActiveFrame }
+ end
+ EssentialCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+
+ local config = makeViewersConfig({ { stackKey = "trinket1" } }, {})
+ ExtraIcons.GetModuleConfig = function()
+ return config
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("utility"))
+
+ local _, _, _, utilityBeforeX = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal(-13, utilityBeforeX)
+
+ config.viewers.utility = {}
+ config.viewers.main = { { stackKey = "trinket1" } }
+
+ assert.is_true(ExtraIcons:UpdateLayout("main"))
+
+ local _, _, _, utilityAfterX = UtilityCooldownViewer:GetPoint(1)
+ local _, _, _, mainAfterX = EssentialCooldownViewer:GetPoint(1)
+
+ assert.are.equal(-13, utilityAfterX)
+ assert.are.equal(87, mainAfterX)
+ assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
+ assert.is_true(ExtraIcons._viewers.main.container:IsShown())
+ end)
+
it("prefers demonic healthstone over the legacy healthstone", function()
local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
utilityIconChild.GetSpellID = function() return 1 end
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 1e64c3e2..23f1f63b 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -811,6 +811,9 @@ TestHelpers.OPTIONS_GLOBALS = {
"time",
"C_PartyInfo",
"IsInInstance",
+ "GetInventoryItemTexture",
+ "C_Item",
+ "C_Spell",
}
--- Load the live Constants.lua and Locales/en.lua to populate ECM.Constants and ECM.L.
@@ -857,6 +860,22 @@ function TestHelpers.SetupOptionsGlobals()
_G.IsInInstance = function()
return false
end
+ _G.GetInventoryItemTexture = function()
+ return nil
+ end
+ _G.C_Item = {
+ GetItemIconByID = function()
+ return nil
+ end,
+ }
+ _G.C_Spell = {
+ GetSpellName = function()
+ return nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
_G.UnitName = function()
return "TestPlayer"
end
@@ -908,6 +927,8 @@ function TestHelpers.SetupOptionsGlobals()
local f = { scripts = {}, hooks = {}, callbacks = {}, _children = {} }
local noop = function() end
local value, minValue, maxValue, stepValue = nil, 0, 0, 1
+ local shown = false
+ local mouseEnabled = false
f.SetScript = function(self, event, fn)
self.scripts[event] = fn
end
@@ -927,13 +948,22 @@ function TestHelpers.SetupOptionsGlobals()
f.SetPoint = noop
f.SetAllPoints = noop
f.ClearAllPoints = noop
- f.Show = noop
- f.Hide = noop
+ f.Show = function()
+ shown = true
+ end
+ f.Hide = function()
+ shown = false
+ end
f.IsShown = function()
- return false
+ return shown
end
f.SetAlpha = noop
- f.EnableMouse = noop
+ f.EnableMouse = function(_, enabled)
+ mouseEnabled = not not enabled
+ end
+ f.IsMouseEnabled = function()
+ return mouseEnabled
+ end
f.GetChildren = function()
return
end
@@ -948,6 +978,8 @@ function TestHelpers.SetupOptionsGlobals()
f.SetWordWrap = noop
f.SetJustifyH = noop
f.SetColorRGB = noop
+ f.SetColorTexture = noop
+ f.SetTexture = noop
f.SetFocus = noop
f.ClearFocus = noop
f.HighlightText = noop
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 241d6831..49107e86 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -397,7 +397,7 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns
+ local profile, defaults, SB, ns, capturedTable
setup(function()
originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
@@ -413,36 +413,85 @@ describe("ExtraIconsOptions settings page", function()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+ local originalRegisterFromTable = SB.RegisterFromTable
+ SB.RegisterFromTable = function(tbl)
+ capturedTable = tbl
+ return originalRegisterFromTable(tbl)
+ end
+
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ns.OptionsSections.ExtraIcons.RegisterSettings(SB)
end)
- describe("canvas creation", function()
- it("canvas is stored on ExtraIconsOptions", function()
- local canvas = ns.ExtraIconsOptions._canvas
- assert.is_not_nil(canvas)
+ describe("settings registration", function()
+ it("creates a subcategory", function()
+ assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
end)
- it("canvas exposes viewer state", function()
- local canvas = ns.ExtraIconsOptions._canvas
- assert.is_table(canvas._viewerRowPools)
- assert.is_table(canvas._customForm)
- assert.is_table(canvas._viewerHeaders)
- assert.is_table(canvas._viewerEmptyLabels)
+ it("only exposes the viewer canvas for testing", function()
+ local opts = ns.ExtraIconsOptions
+ assert.is_not_nil(opts._viewerCanvas)
+ assert.is_nil(opts._addFormCanvas)
+ assert.is_nil(opts._presetsCanvas)
end)
- it("canvas has enabled checkbox", function()
- local canvas = ns.ExtraIconsOptions._canvas
- assert.is_not_nil(canvas._enabledCheck)
+ it("viewer canvas exposes row pools and headers", function()
+ local vc = ns.ExtraIconsOptions._viewerCanvas
+ assert.is_table(vc._viewerRowPools)
+ assert.is_table(vc._viewerHeaders)
+ assert.is_table(vc._viewerEmptyLabels)
end)
- it("canvas has content region", function()
- local canvas = ns.ExtraIconsOptions._canvas
- assert.is_not_nil(canvas._contentRegion)
+ it("registers native quick-add buttons and no custom add form fields", function()
+ assert.is_not_nil(capturedTable)
+ assert.is_nil(capturedTable.args.addHeader)
+ assert.is_nil(capturedTable.args.addType)
+ assert.is_nil(capturedTable.args.addViewer)
+ assert.is_nil(capturedTable.args.addForm)
+ assert.is_nil(capturedTable.args.defaults)
+ assert.is_not_nil(capturedTable.args.quickAdd_trinket1)
+ assert.are.equal("button", capturedTable.args.quickAdd_trinket1.type)
+ assert.are.equal("button", capturedTable.args.quickAddRacial.type)
+ assert.are.equal("canvas", capturedTable.args.viewers.type)
end)
- it("creates a canvas subcategory", function()
- assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
+ it("exposes a refresh function", function()
+ assert.is_function(ns.ExtraIconsOptions._refresh)
+ end)
+
+ it("rebinds whole-row mouseover handlers on refresh", function()
+ ns.ExtraIconsOptions._refresh()
+
+ local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
+ assert.is_not_nil(row)
+ assert.is_not_nil(row._highlight)
+ assert.is_true(row:IsMouseEnabled())
+ assert.is_function(row:GetScript("OnEnter"))
+ assert.is_function(row:GetScript("OnLeave"))
+ assert.is_false(row._highlight:IsShown())
+
+ row:GetScript("OnEnter")(row)
+ assert.is_true(row._highlight:IsShown())
+
+ row:GetScript("OnLeave")(row)
+ assert.is_false(row._highlight:IsShown())
+ end)
+
+ it("resets and rebinds pooled row mouseover on subsequent refreshes", function()
+ ns.ExtraIconsOptions._refresh()
+
+ local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
+ assert.is_not_nil(row)
+
+ row:GetScript("OnEnter")(row)
+ assert.is_true(row._highlight:IsShown())
+
+ ns.ExtraIconsOptions._refresh()
+
+ assert.is_true(row:IsMouseEnabled())
+ assert.is_function(row:GetScript("OnEnter"))
+ assert.is_function(row:GetScript("OnLeave"))
+ assert.is_false(row._highlight:IsShown())
end)
end)
end)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 70bf2aa1..7de3316a 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -13,7 +13,7 @@ local RACIAL_ABILITIES = C.RACIAL_ABILITIES
local ROW_HEIGHT = 26
local ICON_SIZE = 20
local BTN_SIZE = 22
-local CANVAS_MARGIN = 37
+local CONTENT_MARGIN = 10
local VIEWER_ORDER = { "utility", "main" }
local VIEWER_LABELS = {
utility = "UTILITY_VIEWER_ICONS",
@@ -188,57 +188,40 @@ end
local function setButtonTooltip(btn, text)
btn:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- GameTooltip:SetText(text)
+ GameTooltip:SetText(text, 1, 1, 1)
GameTooltip:Show()
end)
btn:SetScript("OnLeave", GameTooltip_Hide)
end
---- Set the entry-specific tooltip on a row's hit region.
---- For spells: SetSpellByID; for items: SetItemByID; otherwise: text-only.
-local function setEntryTooltip(row, entry)
- row:EnableMouse(true)
+local function clearRowMouseover(row)
+ row:SetScript("OnEnter", nil)
+ row:SetScript("OnLeave", nil)
+ if row._highlight then
+ row._highlight:Hide()
+ end
+ if row.EnableMouse then
+ row:EnableMouse(false)
+ end
+end
+
+local function setRowMouseover(row)
+ if row._highlight then
+ row._highlight:Hide()
+ end
+ if row.EnableMouse then
+ row:EnableMouse(true)
+ end
row:SetScript("OnEnter", function(self)
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if entry.kind == "spell" and entry.ids then
- local first = entry.ids[1]
- local spellId = type(first) == "table" and first.spellId or first
- if spellId then
- GameTooltip:SetSpellByID(spellId)
- GameTooltip:Show()
- return
- end
- end
- if entry.kind == "item" and entry.ids then
- local first = entry.ids[1]
- local itemId = type(first) == "table" and first.itemID or first
- if itemId then
- GameTooltip:SetItemByID(itemId)
- GameTooltip:Show()
- return
- end
+ if self._highlight then
+ self._highlight:Show()
end
- if entry.stackKey then
- local stack = BUILTIN_STACKS[entry.stackKey]
- if stack and stack.kind == "equipSlot" then
- GameTooltip:SetInventoryItem("player", stack.slotId)
- GameTooltip:Show()
- return
- end
- if stack and stack.ids and stack.ids[1] then
- local first = stack.ids[1]
- local itemId = type(first) == "table" and first.itemID or first
- if itemId then
- GameTooltip:SetItemByID(itemId)
- GameTooltip:Show()
- return
- end
- end
+ end)
+ row:SetScript("OnLeave", function(self)
+ if self._highlight then
+ self._highlight:Hide()
end
- GameTooltip:SetText(ExtraIconsOptions._getEntryName(entry))
- GameTooltip:Show()
end)
- row:SetScript("OnLeave", GameTooltip_Hide)
end
--- Check if a racial entry belongs to the current player character.
@@ -268,6 +251,11 @@ local function createEntryRow(parent)
local row = CreateFrame("Frame", nil, parent)
row:SetHeight(ROW_HEIGHT)
+ row._highlight = row:CreateTexture(nil, "BACKGROUND")
+ row._highlight:SetAllPoints()
+ row._highlight:SetColorTexture(1, 1, 1, 0.08)
+ row._highlight:Hide()
+
row._icon = row:CreateTexture(nil, "ARTWORK")
row._icon:SetSize(ICON_SIZE, ICON_SIZE)
row._icon:SetPoint("LEFT", 0, 0)
@@ -302,248 +290,94 @@ local function createEntryRow(parent)
end
--------------------------------------------------------------------------------
--- Canvas Layout Page
+-- Embedded Content: Viewer lists
--------------------------------------------------------------------------------
-StaticPopupDialogs["ECM_CONFIRM_RESET_EXTRA_ICONS"] =
- ns.OptionUtil.MakeConfirmDialog(L["EXTRA_ICONS_RESET_CONFIRM"])
-
-local function createCanvasPage(SB)
- local layout = SB.CreateCanvasLayout(L["EXTRA_ICONS"])
- local frame = layout.frame
-
- local viewerRowPools = { utility = {}, main = {} }
- local viewerEmptyLabels = {}
- local viewerHeaders = {}
- local addBtnPool = {}
-
- -- Header with Defaults button
- local headerRow = layout:AddHeader(L["EXTRA_ICONS"])
- local defaultsBtn = headerRow._defaultsButton
- layout:AddSpacer(2)
-
- -- Enabled checkbox — native layout (label left, control right)
- local enabledRow = layout:_CreateRow()
- layout:_AddLabel(enabledRow, L["ENABLE_EXTRA_ICONS"])
- local enabledCheck = CreateFrame("CheckButton", nil, enabledRow, "UICheckButtonTemplate")
- enabledCheck:SetSize(26, 26)
- enabledCheck:SetPoint("LEFT", enabledRow, "CENTER", -80, 0)
- frame._enabledCheck = enabledCheck
-
- enabledCheck:SetScript("OnClick", function(self)
- local enabled = self:GetChecked()
- ns.Addon.db.profile.extraIcons.enabled = enabled
- local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
- handler(enabled)
- frame:Refresh()
- end)
+local function createViewerListCanvas()
+ local frame = CreateFrame("Frame")
+ frame:SetHeight(400)
- layout:AddSpacer(6)
-
- -- Content region (dims when disabled)
- local contentRegion = CreateFrame("Frame", nil, frame)
- contentRegion:SetPoint("TOPLEFT", 0, layout.yPos)
- contentRegion:SetPoint("BOTTOMRIGHT")
- frame._contentRegion = contentRegion
-
- local contentY = 0
-
- -- Predefined add buttons container
- local addBtnContainer = CreateFrame("Frame", nil, contentRegion)
- addBtnContainer:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, contentY)
- addBtnContainer:SetPoint("RIGHT", contentRegion, "RIGHT", -20, 0)
- addBtnContainer:SetHeight(28)
- contentY = contentY - 36
-
- -- Custom entry form — label on left, controls right of center
- local formRow = CreateFrame("Frame", nil, contentRegion)
- formRow:SetHeight(26)
- formRow:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", 0, contentY)
- formRow:SetPoint("RIGHT", contentRegion, "RIGHT", 0, 0)
-
- local formLabel = formRow:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- formLabel:SetPoint("LEFT", CANVAS_MARGIN, 0)
- formLabel:SetPoint("RIGHT", formRow, "CENTER", -85, 0)
- formLabel:SetJustifyH("LEFT")
- formLabel:SetText(L["ADD_CUSTOM_IDS"])
-
- local editBox = CreateFrame("EditBox", nil, formRow, "InputBoxInstructionsTemplate")
- editBox:SetSize(140, 22)
- editBox:SetPoint("LEFT", formRow, "CENTER", -80, 0)
- editBox:SetAutoFocus(false)
- editBox.Instructions:SetText("1234, 5678")
-
- local viewerToggle = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
- viewerToggle:SetSize(60, 22)
- viewerToggle:SetPoint("LEFT", editBox, "RIGHT", 4, 0)
-
- local addSpellBtn = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
- addSpellBtn:SetSize(72, 22)
- addSpellBtn:SetPoint("LEFT", viewerToggle, "RIGHT", 4, 0)
- addSpellBtn:SetText(L["ADD_SPELL"])
-
- local addItemBtn = CreateFrame("Button", nil, formRow, "UIPanelButtonTemplate")
- addItemBtn:SetSize(68, 22)
- addItemBtn:SetPoint("LEFT", addSpellBtn, "RIGHT", 4, 0)
- addItemBtn:SetText(L["ADD_ITEM"])
-
- local customViewerKey = "utility"
-
- frame._customForm = {
- frame = formRow,
- editBox = editBox,
- viewerToggle = viewerToggle,
- addSpellBtn = addSpellBtn,
- addItemBtn = addItemBtn,
- }
+ frame._viewerRowPools = { utility = {}, main = {} }
+ frame._viewerHeaders = {}
+ frame._viewerEmptyLabels = {}
- contentY = contentY - 36
-
- -- Per-viewer headers and empty labels
for _, vk in ipairs(VIEWER_ORDER) do
- viewerHeaders[vk] = contentRegion:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
- viewerHeaders[vk]:SetJustifyH("LEFT")
- viewerHeaders[vk]:SetText(L[VIEWER_LABELS[vk]])
+ frame._viewerHeaders[vk] = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
+ frame._viewerHeaders[vk]:SetJustifyH("LEFT")
+ frame._viewerHeaders[vk]:SetText(L[VIEWER_LABELS[vk]])
- viewerEmptyLabels[vk] = contentRegion:CreateFontString(nil, "OVERLAY", "GameFontDisable")
- viewerEmptyLabels[vk]:SetJustifyH("LEFT")
- viewerEmptyLabels[vk]:SetText(L["EXTRA_ICONS_NO_ENTRIES"])
+ frame._viewerEmptyLabels[vk] = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ frame._viewerEmptyLabels[vk]:SetJustifyH("LEFT")
+ frame._viewerEmptyLabels[vk]:SetText(L["EXTRA_ICONS_NO_ENTRIES"])
end
- -- Expose state for testing
- frame._viewerRowPools = viewerRowPools
- frame._viewerHeaders = viewerHeaders
- frame._viewerEmptyLabels = viewerEmptyLabels
- frame._addBtnContainer = addBtnContainer
- frame._addBtnPool = addBtnPool
+ return frame
+end
+
+--------------------------------------------------------------------------------
+-- Settings Registration
+--------------------------------------------------------------------------------
+
+StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
+ ns.OptionUtil.MakeConfirmDialog(L["REMOVE_ENTRY_CONFIRM"])
+
+function ExtraIconsOptions.RegisterSettings(SB)
+ local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
+ local viewerCanvas = createViewerListCanvas()
+ ExtraIconsOptions._viewerCanvas = viewerCanvas
+ ExtraIconsOptions._addFormCanvas = nil
+ ExtraIconsOptions._presetsCanvas = nil
local function getProfile()
return ns.Addon.db.profile
end
+ local function getViewers()
+ return getProfile().extraIcons.viewers
+ end
+
local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
- local function resetToDefaults()
- local viewers = getProfile().extraIcons.viewers
- wipe(viewers)
- viewers.utility = {}
- for _, key in ipairs(BUILTIN_STACK_ORDER) do
- viewers.utility[#viewers.utility + 1] = { stackKey = key }
+ local function refreshVisibleSettingsControls()
+ local panel = SettingsPanel
+ if not panel or not panel.IsShown or not panel:IsShown() then
+ return
end
- viewers.main = {}
- scheduleUpdate()
- frame:Refresh()
- end
-
- -- Defaults button
- defaultsBtn:SetText(SETTINGS_DEFAULTS)
- defaultsBtn:SetScript("OnClick", function()
- StaticPopup_Show("ECM_CONFIRM_RESET_EXTRA_ICONS", nil, nil, {
- onAccept = resetToDefaults,
- })
- end)
- -- Custom form: viewer toggle
- local function updateViewerToggle()
- viewerToggle:SetText(customViewerKey == "utility" and "Utility" or "Main")
- end
- updateViewerToggle()
- viewerToggle:SetScript("OnClick", function()
- customViewerKey = customViewerKey == "utility" and "main" or "utility"
- updateViewerToggle()
- end)
-
- -- Custom form: add handlers
- local function addCustom(kind)
- local text = editBox:GetText()
- local ids = ExtraIconsOptions._parseIds(text)
- if not ids then return end
- ExtraIconsOptions._addCustomEntry(getProfile(), customViewerKey, kind, ids)
- editBox:SetText("")
- scheduleUpdate()
- frame:Refresh()
- end
- addSpellBtn:SetScript("OnClick", function() addCustom("spell") end)
- addItemBtn:SetScript("OnClick", function() addCustom("item") end)
-
- function frame:Refresh()
- local profile = getProfile()
- local viewers = profile.extraIcons.viewers
- local enabled = profile.extraIcons.enabled
- enabledCheck:SetChecked(enabled)
- contentRegion:SetAlpha(enabled and 1 or 0.5)
-
- -- Hide all add buttons
- for _, btn in ipairs(addBtnPool) do
- btn:Hide()
- end
-
- -- Add predefined stack buttons
- local btnIndex = 0
- local xOffset = 0
- for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- btnIndex = btnIndex + 1
- local btn = addBtnPool[btnIndex]
- if not btn then
- btn = CreateFrame("Button", nil, addBtnContainer, "UIPanelButtonTemplate")
- btn:SetHeight(24)
- addBtnPool[btnIndex] = btn
+ local settingsList = panel.GetSettingsList and panel:GetSettingsList()
+ local scrollBox = settingsList and settingsList.ScrollBox
+ if scrollBox and scrollBox.ForEachFrame then
+ scrollBox:ForEachFrame(function(frame)
+ if frame.EvaluateState then
+ frame:EvaluateState()
end
- local stack = BUILTIN_STACKS[stackKey]
- local key = stackKey
- btn:SetText(stack.label)
- btn:SetWidth(84)
- btn:ClearAllPoints()
- btn:SetPoint("LEFT", xOffset, 0)
- btn:SetScript("OnClick", function()
- ExtraIconsOptions._addStackKey(getProfile(), "utility", key)
- scheduleUpdate()
- frame:Refresh()
- end)
- btn:Show()
- xOffset = xOffset + 90
- end
+ end)
end
+ end
- -- Racial button
+ local function getPlayerRacialSpellId()
local _, raceFile = UnitRace("player")
local racial = raceFile and RACIAL_ABILITIES[raceFile]
- if racial and not ExtraIconsOptions._isRacialPresent(viewers, racial.spellId) then
- btnIndex = btnIndex + 1
- local btn = addBtnPool[btnIndex]
- if not btn then
- btn = CreateFrame("Button", nil, addBtnContainer, "UIPanelButtonTemplate")
- btn:SetHeight(24)
- addBtnPool[btnIndex] = btn
- end
- local capturedSpellId = racial.spellId
- local racialName = C_Spell.GetSpellName(capturedSpellId) or "Racial"
- btn:SetText(L["ADD_RACIAL"]:format(racialName))
- btn:SetWidth(btn:GetTextWidth() + 20)
- btn:ClearAllPoints()
- btn:SetPoint("LEFT", xOffset, 0)
- btn:SetScript("OnClick", function()
- ExtraIconsOptions._addRacial(getProfile(), "utility", capturedSpellId)
- scheduleUpdate()
- frame:Refresh()
- end)
- btn:Show()
- end
+ return racial and racial.spellId or nil
+ end
+
+ --------------------------------------------------------------------
+ -- Refresh: viewer lists canvas
+ --------------------------------------------------------------------
+ local function refreshViewerLists()
+ local viewers = getViewers()
+ local y = 0
- -- Viewer lists
- local y = contentY
for _, viewerKey in ipairs(VIEWER_ORDER) do
- viewerHeaders[viewerKey]:ClearAllPoints()
- viewerHeaders[viewerKey]:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, y)
+ viewerCanvas._viewerHeaders[viewerKey]:ClearAllPoints()
+ viewerCanvas._viewerHeaders[viewerKey]:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN, y)
y = y - 18
- local pool = viewerRowPools[viewerKey]
+ local pool = viewerCanvas._viewerRowPools[viewerKey]
local entries = viewers[viewerKey] or {}
- -- Filter out racials not for this character
local visibleEntries = {}
for i, entry in ipairs(entries) do
if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
@@ -552,16 +386,17 @@ local function createCanvasPage(SB)
end
for _, row in ipairs(pool) do
+ clearRowMouseover(row)
row:Hide()
end
if #visibleEntries == 0 then
- viewerEmptyLabels[viewerKey]:ClearAllPoints()
- viewerEmptyLabels[viewerKey]:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN + 8, y)
- viewerEmptyLabels[viewerKey]:Show()
+ viewerCanvas._viewerEmptyLabels[viewerKey]:ClearAllPoints()
+ viewerCanvas._viewerEmptyLabels[viewerKey]:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN + 8, y)
+ viewerCanvas._viewerEmptyLabels[viewerKey]:Show()
y = y - ROW_HEIGHT
else
- viewerEmptyLabels[viewerKey]:Hide()
+ viewerCanvas._viewerEmptyLabels[viewerKey]:Hide()
end
for vi, vis in ipairs(visibleEntries) do
@@ -569,23 +404,17 @@ local function createCanvasPage(SB)
local ci = vis.index
local row = pool[vi]
if not row then
- row = createEntryRow(contentRegion)
+ row = createEntryRow(viewerCanvas)
pool[vi] = row
end
- local entryName = ExtraIconsOptions._getEntryName(entry)
- row._label:SetText(entryName)
- local icon = ExtraIconsOptions._getEntryIcon(entry)
- row._icon:SetTexture(icon or 134400)
+ row._label:SetText(ExtraIconsOptions._getEntryName(entry))
+ row._icon:SetTexture(ExtraIconsOptions._getEntryIcon(entry) or 134400)
row._upBtn:SetEnabled(ci > 1)
row._downBtn:SetEnabled(ci < #entries)
local other = ExtraIconsOptions._otherViewer(viewerKey)
- -- Entry tooltip on hover
- setEntryTooltip(row, entry)
-
- -- Button tooltips
setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(other))
@@ -594,32 +423,35 @@ local function createCanvasPage(SB)
row._upBtn:SetScript("OnClick", function()
ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, -1)
scheduleUpdate()
- frame:Refresh()
+ ExtraIconsOptions._refresh()
end)
row._downBtn:SetScript("OnClick", function()
ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, 1)
scheduleUpdate()
- frame:Refresh()
+ ExtraIconsOptions._refresh()
end)
row._moveBtn:SetText(viewerKey == "utility" and ">" or "<")
row._moveBtn:SetScript("OnClick", function()
ExtraIconsOptions._moveEntry(getProfile(), viewerKey, other, ci)
scheduleUpdate()
- frame:Refresh()
+ ExtraIconsOptions._refresh()
end)
row._deleteBtn:SetScript("OnClick", function()
+ local entryName = ExtraIconsOptions._getEntryName(entry)
StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
onAccept = function()
ExtraIconsOptions._removeEntry(getProfile(), viewerKey, ci)
scheduleUpdate()
- frame:Refresh()
+ ExtraIconsOptions._refresh()
end,
})
end)
+ setRowMouseover(row)
+
row:ClearAllPoints()
- row:SetPoint("TOPLEFT", contentRegion, "TOPLEFT", CANVAS_MARGIN, y)
- row:SetPoint("RIGHT", contentRegion, "RIGHT", -20, 0)
+ row:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN, y)
+ row:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
row:Show()
y = y - ROW_HEIGHT
end
@@ -628,23 +460,111 @@ local function createCanvasPage(SB)
end
end
- frame:SetScript("OnShow", function(self)
- self:Refresh()
- end)
+ --------------------------------------------------------------------
+ -- Combined refresh
+ --------------------------------------------------------------------
+ function ExtraIconsOptions._refresh()
+ refreshViewerLists()
+ refreshVisibleSettingsControls()
+ end
- return frame
-end
+ local function addStackPreset(stackKey)
+ local viewers = getViewers()
+ if ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ return
+ end
---------------------------------------------------------------------------------
--- Settings Registration
---------------------------------------------------------------------------------
+ ExtraIconsOptions._addStackKey(getProfile(), "utility", stackKey)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end
-StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
- ns.OptionUtil.MakeConfirmDialog(L["REMOVE_ENTRY_CONFIRM"])
+ local function addRacialPreset()
+ local spellId = getPlayerRacialSpellId()
+ local viewers = getViewers()
+ if not spellId or ExtraIconsOptions._isRacialPresent(viewers, spellId) then
+ return
+ end
-function ExtraIconsOptions.RegisterSettings(SB)
- local canvasFrame = createCanvasPage(SB)
- ExtraIconsOptions._canvas = canvasFrame
+ ExtraIconsOptions._addRacial(getProfile(), "utility", spellId)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end
+
+ --------------------------------------------------------------------
+ -- Register via table
+ --------------------------------------------------------------------
+ local args = {
+ enabled = {
+ type = "toggle",
+ path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"],
+ desc = L["ENABLE_EXTRA_ICONS_DESC"],
+ order = 0,
+ onSet = function(value)
+ local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
+ handler(value)
+ end,
+ },
+ presetsHeader = {
+ type = "header",
+ name = L["PRESETS_HEADER"],
+ order = 10,
+ disabled = isDisabled,
+ },
+ }
+
+ -- Custom spell/item entry form intentionally omitted here.
+ -- LibSettingsBuilder does not provide a native text input control, and this
+ -- page must not recreate one with a canvas embed.
+ for i, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ local stack = BUILTIN_STACKS[stackKey]
+ args["quickAdd_" .. stackKey] = {
+ type = "button",
+ name = stack.label,
+ buttonText = L["ADD_ENTRY"],
+ hidden = function()
+ return ExtraIconsOptions._isStackKeyPresent(getViewers(), stackKey)
+ end,
+ disabled = isDisabled,
+ order = 10 + i,
+ onClick = function()
+ addStackPreset(stackKey)
+ end,
+ }
+ end
+
+ local racialSpellId = getPlayerRacialSpellId()
+ local racialName = racialSpellId and C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(racialSpellId) or nil
+ args.quickAddRacial = {
+ type = "button",
+ name = racialName or "Racial",
+ buttonText = L["ADD_ENTRY"],
+ hidden = function()
+ local spellId = getPlayerRacialSpellId()
+ return spellId == nil or ExtraIconsOptions._isRacialPresent(getViewers(), spellId)
+ end,
+ disabled = isDisabled,
+ order = 11 + #BUILTIN_STACK_ORDER,
+ onClick = addRacialPreset,
+ }
+
+ args.viewers = {
+ type = "canvas",
+ canvas = viewerCanvas,
+ height = 400,
+ disabled = isDisabled,
+ order = 30,
+ }
+
+ SB.RegisterFromTable({
+ name = L["EXTRA_ICONS"],
+ path = "extraIcons",
+ onShow = function()
+ ExtraIconsOptions._refresh()
+ end,
+ args = args,
+ })
end
ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
From 97c0d440bae4ebaff3fb0ee1c8446dfd76b1b1a1 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 8 Apr 2026 08:35:42 +1000
Subject: [PATCH 03/53] Correct the subheader styling. Move quick add.
---
.../LibSettingsBuilder/LibSettingsBuilder.lua | 36 +++++++--
.../Tests/LibSettingsBuilder_spec.lua | 64 +++++++++++++++
Tests/TestHelpers.lua | 33 +++++++-
Tests/UI/ExtraIconsOptions_spec.lua | 49 ++++++++++++
UI/ExtraIconsOptions.lua | 79 +++++++++++++------
5 files changed, 230 insertions(+), 31 deletions(-)
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index 8e835a8d..775dc5d5 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -189,15 +189,40 @@ local function resetPlainListElementFrame(frame)
resetListElement(frame)
end
+local function createSubheaderTitle(parent, text)
+ local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ title:SetPoint("TOPLEFT", parent, "TOPLEFT", 35, -8)
+ title:SetJustifyH("LEFT")
+ title:SetJustifyV("TOP")
+ title:SetFontObject(GameFontHighlight)
+ if text ~= nil then
+ title:SetText(text)
+ end
+ title:Show()
+ return title
+end
+
+local function createHeaderTitle(parent, text)
+ local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
+ title:SetPoint("TOPLEFT", parent, "TOPLEFT", 7, -16)
+ title:SetJustifyH("LEFT")
+ title:SetJustifyV("TOP")
+ if text ~= nil then
+ title:SetText(text)
+ end
+ title:Show()
+ return title
+end
+
+lib.CreateHeaderTitle = createHeaderTitle
+lib.CreateSubheaderTitle = createSubheaderTitle
+
local function ensureSubheaderTitle(frame)
if frame._lsbSubheaderTitle then
return frame._lsbSubheaderTitle
end
- local title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- title:SetPoint("TOPLEFT", 35, -8)
- title:SetJustifyH("LEFT")
- title:SetJustifyV("TOP")
+ local title = createSubheaderTitle(frame)
frame._lsbSubheaderTitle = title
frame.Title = title
return title
@@ -227,7 +252,6 @@ end
local function applySubheaderFrame(frame, data)
local title = ensureSubheaderTitle(frame)
- title:SetFontObject(GameFontHighlight)
title:SetText(data.name)
title:Show()
end
@@ -874,6 +898,8 @@ function lib:New(config)
SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
+ SB.CreateHeaderTitle = lib.CreateHeaderTitle
+ SB.CreateSubheaderTitle = lib.CreateSubheaderTitle
----------------------------------------------------------------------------
-- Internal helpers
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index f25bcc63..da29aab5 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -164,6 +164,7 @@ describe("LibSettingsBuilder", function()
"SettingsListElementMixin",
"SettingsDropdownControlMixin",
"SettingsSliderControlMixin",
+ "GameFontHighlight",
"GameFontHighlightSmall",
"GameFontNormal",
})
@@ -180,6 +181,7 @@ describe("LibSettingsBuilder", function()
TestHelpers.SetupSettingsStubs()
_G.ECM_DeepEquals = TestHelpers.deepEquals
+ _G.GameFontHighlight = "GameFontHighlight"
_G.GameFontHighlightSmall = "GameFontHighlightSmall"
_G.GameFontNormal = "GameFontNormal"
@@ -482,6 +484,68 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Test Header", init._text)
end)
+ it("CreateSubheaderTitle applies the standard subheader styling", function()
+ local parent = createScriptableFrame()
+ parent.CreateFontString = function(_, _, _, fontTemplate)
+ local fontString = createScriptableFrame()
+ fontString._fontTemplate = fontTemplate
+ fontString.SetFontObject = function(self, value)
+ self._fontObject = value
+ end
+ fontString.SetJustifyH = function(self, value)
+ self._justifyH = value
+ end
+ fontString.SetJustifyV = function(self, value)
+ self._justifyV = value
+ end
+ return fontString
+ end
+
+ local title = SB.CreateSubheaderTitle(parent, "Viewer Icons")
+ local point, relativeTo, relativePoint, x, y = title:GetPoint(1)
+
+ assert.are.equal("GameFontHighlightSmall", title._fontTemplate)
+ assert.are.equal("TOPLEFT", point)
+ assert.are.equal(parent, relativeTo)
+ assert.are.equal("TOPLEFT", relativePoint)
+ assert.are.equal(35, x)
+ assert.are.equal(-8, y)
+ assert.are.equal("LEFT", title._justifyH)
+ assert.are.equal("TOP", title._justifyV)
+ assert.are.equal("GameFontHighlight", title._fontObject)
+ assert.are.equal("Viewer Icons", title:GetText())
+ assert.is_true(title:IsShown())
+ end)
+
+ it("CreateHeaderTitle applies the standard header styling", function()
+ local parent = createScriptableFrame()
+ parent.CreateFontString = function(_, _, _, fontTemplate)
+ local fontString = createScriptableFrame()
+ fontString._fontTemplate = fontTemplate
+ fontString.SetJustifyH = function(self, value)
+ self._justifyH = value
+ end
+ fontString.SetJustifyV = function(self, value)
+ self._justifyV = value
+ end
+ return fontString
+ end
+
+ local title = SB.CreateHeaderTitle(parent, "Viewer Icons")
+ local point, relativeTo, relativePoint, x, y = title:GetPoint(1)
+
+ assert.are.equal("GameFontHighlightLarge", title._fontTemplate)
+ assert.are.equal("TOPLEFT", point)
+ assert.are.equal(parent, relativeTo)
+ assert.are.equal("TOPLEFT", relativePoint)
+ assert.are.equal(7, x)
+ assert.are.equal(-16, y)
+ assert.are.equal("LEFT", title._justifyH)
+ assert.are.equal("TOP", title._justifyV)
+ assert.are.equal("Viewer Icons", title:GetText())
+ assert.is_true(title:IsShown())
+ end)
+
-- Subheader
it("Subheader adds element initializer with normal font template", function()
local init = SB.Subheader({ name = "Item Quality" })
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 23f1f63b..ccd02bef 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -929,6 +929,9 @@ function TestHelpers.SetupOptionsGlobals()
local value, minValue, maxValue, stepValue = nil, 0, 0, 1
local shown = false
local mouseEnabled = false
+ local text = ""
+ local fontObject = nil
+ local anchors = {}
f.SetScript = function(self, event, fn)
self.scripts[event] = fn
end
@@ -945,9 +948,22 @@ function TestHelpers.SetupOptionsGlobals()
f.SetHeight = noop
f.SetWidth = noop
f.SetSize = noop
- f.SetPoint = noop
+ f.SetPoint = function(_, point, relativeTo, relativePoint, x, y)
+ anchors[#anchors + 1] = { point, relativeTo, relativePoint, x, y }
+ end
f.SetAllPoints = noop
- f.ClearAllPoints = noop
+ f.ClearAllPoints = function()
+ anchors = {}
+ end
+ f.GetPoint = function(_, index)
+ local anchor = anchors[index or 1]
+ if anchor then
+ return anchor[1], anchor[2], anchor[3], anchor[4], anchor[5]
+ end
+ end
+ f.GetNumPoints = function()
+ return #anchors
+ end
f.Show = function()
shown = true
end
@@ -971,15 +987,24 @@ function TestHelpers.SetupOptionsGlobals()
f.RegisterForClicks = noop
f.SetAutoFocus = noop
f.SetNumeric = noop
- f.SetText = noop
+ f.SetText = function(_, newText)
+ text = newText
+ end
f.GetText = function()
- return ""
+ return text
end
f.SetWordWrap = noop
f.SetJustifyH = noop
+ f.SetJustifyV = noop
f.SetColorRGB = noop
f.SetColorTexture = noop
f.SetTexture = noop
+ f.SetFontObject = function(_, newFontObject)
+ fontObject = newFontObject
+ end
+ f.GetFontObject = function()
+ return fontObject
+ end
f.SetFocus = noop
f.ClearFocus = noop
f.HighlightText = noop
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 49107e86..0745c22f 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -440,6 +440,10 @@ describe("ExtraIconsOptions settings page", function()
assert.is_table(vc._viewerRowPools)
assert.is_table(vc._viewerHeaders)
assert.is_table(vc._viewerEmptyLabels)
+ assert.is_not_nil(vc._viewerHeaders.utility._title)
+ assert.is_not_nil(vc._viewerHeaders.main._title)
+ assert.are.equal(ns.L["UTILITY_VIEWER_ICONS"], vc._viewerHeaders.utility._title:GetText())
+ assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], vc._viewerHeaders.main._title:GetText())
end)
it("registers native quick-add buttons and no custom add form fields", function()
@@ -453,12 +457,57 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("button", capturedTable.args.quickAdd_trinket1.type)
assert.are.equal("button", capturedTable.args.quickAddRacial.type)
assert.are.equal("canvas", capturedTable.args.viewers.type)
+ assert.is_true(capturedTable.args.viewers.order < capturedTable.args.presetsHeader.order)
+ assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAdd_trinket1.order)
+ assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAddRacial.order)
+ end)
+
+ it("hides the quick-add heading when no quick-add entries are visible", function()
+ local viewers = profile.extraIcons.viewers
+ local racial = ns.Constants.RACIAL_ABILITIES.Human
+ viewers.utility = {}
+
+ for _, stackKey in ipairs(ns.Constants.BUILTIN_STACK_ORDER) do
+ viewers.utility[#viewers.utility + 1] = { stackKey = stackKey }
+ end
+ viewers.utility[#viewers.utility + 1] = { kind = "spell", ids = { racial.spellId } }
+
+ assert.is_true(capturedTable.args.presetsHeader.hidden())
+
+ ns.ExtraIconsOptions._removeEntry(profile, "utility", 1)
+
+ assert.is_false(capturedTable.args.presetsHeader.hidden())
+ assert.is_false(capturedTable.args.quickAdd_trinket1.hidden())
end)
it("exposes a refresh function", function()
assert.is_function(ns.ExtraIconsOptions._refresh)
end)
+ it("redisplays the active category so removed quick-add entries can reappear", function()
+ local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
+ local redisplayedCategory = nil
+
+ rawset(SettingsPanel, "IsShown", function()
+ return true
+ end)
+ rawset(SettingsPanel, "GetCurrentCategory", function()
+ return category
+ end)
+ rawset(SettingsPanel, "DisplayCategory", function(_, cat)
+ redisplayedCategory = cat
+ end)
+
+ profile.extraIcons.viewers.utility = { { stackKey = "trinket1" } }
+ assert.is_true(capturedTable.args.quickAdd_trinket1.hidden())
+
+ ns.ExtraIconsOptions._removeEntry(profile, "utility", 1)
+ ns.ExtraIconsOptions._refresh()
+
+ assert.is_false(capturedTable.args.quickAdd_trinket1.hidden())
+ assert.are.equal(category, redisplayedCategory)
+ end)
+
it("rebinds whole-row mouseover handlers on refresh", function()
ns.ExtraIconsOptions._refresh()
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 7de3316a..e5a9325e 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -289,11 +289,18 @@ local function createEntryRow(parent)
return row
end
+local function createViewerHeaderRow(parent, SB, text, headerHeight)
+ local row = CreateFrame("Frame", nil, parent)
+ row:SetHeight(headerHeight)
+ row._title = SB.CreateHeaderTitle(row, text)
+ return row
+end
+
--------------------------------------------------------------------------------
-- Embedded Content: Viewer lists
--------------------------------------------------------------------------------
-local function createViewerListCanvas()
+local function createViewerListCanvas(SB, headerHeight)
local frame = CreateFrame("Frame")
frame:SetHeight(400)
@@ -302,9 +309,7 @@ local function createViewerListCanvas()
frame._viewerEmptyLabels = {}
for _, vk in ipairs(VIEWER_ORDER) do
- frame._viewerHeaders[vk] = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
- frame._viewerHeaders[vk]:SetJustifyH("LEFT")
- frame._viewerHeaders[vk]:SetText(L[VIEWER_LABELS[vk]])
+ frame._viewerHeaders[vk] = createViewerHeaderRow(frame, SB, L[VIEWER_LABELS[vk]], headerHeight)
frame._viewerEmptyLabels[vk] = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable")
frame._viewerEmptyLabels[vk]:SetJustifyH("LEFT")
@@ -323,7 +328,9 @@ StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
function ExtraIconsOptions.RegisterSettings(SB)
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
- local viewerCanvas = createViewerListCanvas()
+ local viewerHeaderHeight = (SB.SetCanvasLayoutDefaults and SB.SetCanvasLayoutDefaults().headerHeight) or 50
+ local viewerCanvas = createViewerListCanvas(SB, viewerHeaderHeight)
+ local pageCategory = nil
ExtraIconsOptions._viewerCanvas = viewerCanvas
ExtraIconsOptions._addFormCanvas = nil
ExtraIconsOptions._presetsCanvas = nil
@@ -340,12 +347,36 @@ function ExtraIconsOptions.RegisterSettings(SB)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
+ local function getPlayerRacialSpellId()
+ local _, raceFile = UnitRace("player")
+ local racial = raceFile and RACIAL_ABILITIES[raceFile]
+ return racial and racial.spellId or nil
+ end
+
+ local function hasVisibleQuickAddEntries()
+ local viewers = getViewers()
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ return true
+ end
+ end
+
+ local racialSpellId = getPlayerRacialSpellId()
+ return racialSpellId ~= nil and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId)
+ end
+
local function refreshVisibleSettingsControls()
local panel = SettingsPanel
if not panel or not panel.IsShown or not panel:IsShown() then
return
end
+ local currentCategory = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil
+ if pageCategory and currentCategory == pageCategory and panel.DisplayCategory then
+ panel:DisplayCategory(currentCategory)
+ return
+ end
+
local settingsList = panel.GetSettingsList and panel:GetSettingsList()
local scrollBox = settingsList and settingsList.ScrollBox
if scrollBox and scrollBox.ForEachFrame then
@@ -357,12 +388,6 @@ function ExtraIconsOptions.RegisterSettings(SB)
end
end
- local function getPlayerRacialSpellId()
- local _, raceFile = UnitRace("player")
- local racial = raceFile and RACIAL_ABILITIES[raceFile]
- return racial and racial.spellId or nil
- end
-
--------------------------------------------------------------------
-- Refresh: viewer lists canvas
--------------------------------------------------------------------
@@ -371,9 +396,12 @@ function ExtraIconsOptions.RegisterSettings(SB)
local y = 0
for _, viewerKey in ipairs(VIEWER_ORDER) do
- viewerCanvas._viewerHeaders[viewerKey]:ClearAllPoints()
- viewerCanvas._viewerHeaders[viewerKey]:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN, y)
- y = y - 18
+ local headerRow = viewerCanvas._viewerHeaders[viewerKey]
+ headerRow:ClearAllPoints()
+ headerRow:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", 0, y)
+ headerRow:SetPoint("RIGHT", viewerCanvas, "RIGHT", 0, 0)
+ headerRow:Show()
+ y = y - viewerHeaderHeight
local pool = viewerCanvas._viewerRowPools[viewerKey]
local entries = viewers[viewerKey] or {}
@@ -506,12 +534,6 @@ function ExtraIconsOptions.RegisterSettings(SB)
handler(value)
end,
},
- presetsHeader = {
- type = "header",
- name = L["PRESETS_HEADER"],
- order = 10,
- disabled = isDisabled,
- },
}
-- Custom spell/item entry form intentionally omitted here.
@@ -527,7 +549,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
return ExtraIconsOptions._isStackKeyPresent(getViewers(), stackKey)
end,
disabled = isDisabled,
- order = 10 + i,
+ order = 41 + i,
onClick = function()
addStackPreset(stackKey)
end,
@@ -545,7 +567,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
return spellId == nil or ExtraIconsOptions._isRacialPresent(getViewers(), spellId)
end,
disabled = isDisabled,
- order = 11 + #BUILTIN_STACK_ORDER,
+ order = 42 + #BUILTIN_STACK_ORDER,
onClick = addRacialPreset,
}
@@ -557,6 +579,16 @@ function ExtraIconsOptions.RegisterSettings(SB)
order = 30,
}
+ args.presetsHeader = {
+ type = "header",
+ name = L["PRESETS_HEADER"],
+ order = 40,
+ disabled = isDisabled,
+ hidden = function()
+ return not hasVisibleQuickAddEntries()
+ end,
+ }
+
SB.RegisterFromTable({
name = L["EXTRA_ICONS"],
path = "extraIcons",
@@ -565,6 +597,9 @@ function ExtraIconsOptions.RegisterSettings(SB)
end,
args = args,
})
+
+ pageCategory = SB.GetSubcategory(L["EXTRA_ICONS"])
+ ExtraIconsOptions._category = pageCategory
end
ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
From 857a214b2e7a57a300bf87cb94e454c909fa4423 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 8 Apr 2026 10:32:27 +1000
Subject: [PATCH 04/53] Add input form.
---
.../LibSettingsBuilder/LibSettingsBuilder.lua | 361 +++++++++++++++++-
Libs/LibSettingsBuilder/README.md | 88 ++++-
.../Tests/LibSettingsBuilder_spec.lua | 121 ++++++
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 43 ++-
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 18 +
.../docs/MIGRATION_GUIDE.md | 21 +-
Libs/LibSettingsBuilder/docs/QUICK_START.md | 40 ++
.../docs/TROUBLESHOOTING.md | 28 +-
Locales/en.lua | 4 +
README.md | 28 +-
Tests/TestHelpers.lua | 95 ++++-
Tests/UI/ExtraIconsOptions_spec.lua | 121 +++++-
UI/ExtraIconsOptions.lua | 168 +++++++-
13 files changed, 1082 insertions(+), 54 deletions(-)
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index 775dc5d5..9ec2f990 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -15,6 +15,7 @@ end
lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
+lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
@@ -84,7 +85,15 @@ local function installPageLifecycleHooks()
end)
end
-local listElementKeysToHide = { "_lsbSubheaderTitle", "_lsbInfoTitle", "_lsbInfoValue", "_lsbCanvas" }
+local listElementKeysToHide = {
+ "_lsbSubheaderTitle",
+ "_lsbInfoTitle",
+ "_lsbInfoValue",
+ "_lsbCanvas",
+ "_lsbInputTitle",
+ "_lsbInputEditBox",
+ "_lsbInputPreview",
+}
local function copyMixin(target, source)
for key, value in pairs(source) do
@@ -110,6 +119,21 @@ local function getInitializerData(initializer)
end
end
+local function getSettingVariable(setting)
+ return setting and (setting._lsbVariable or setting._variable)
+end
+
+local function registerValueChangedCallback(frame, variable, callback, owner)
+ if not variable then
+ return
+ end
+
+ local handles = frame and frame.cbrHandles
+ if handles and handles.SetOnValueChangedCallback then
+ handles:SetOnValueChangedCallback(variable, callback, owner or frame)
+ end
+end
+
local function makeStableSortKey(value)
local valueType = type(value)
if valueType == "number" then
@@ -264,6 +288,230 @@ local function applyInfoRowFrame(frame, data)
value:Show()
end
+local function ensureInputRowWidgets(frame)
+ if frame._lsbInputTitle and frame._lsbInputEditBox and frame._lsbInputPreview then
+ return frame._lsbInputTitle, frame._lsbInputEditBox, frame._lsbInputPreview
+ end
+
+ local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ title:SetJustifyH("LEFT")
+ title:SetWordWrap(false)
+
+ local editBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
+ editBox:SetAutoFocus(false)
+
+ local preview = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ preview:SetJustifyH("LEFT")
+ preview:SetJustifyV("TOP")
+ preview:SetWordWrap(false)
+ preview:Hide()
+
+ frame._lsbInputTitle = title
+ frame._lsbInputEditBox = editBox
+ frame._lsbInputPreview = preview
+ frame.Title = title
+ frame.EditBox = editBox
+ frame.Preview = preview
+
+ return title, editBox, preview
+end
+
+local function setInputPreviewText(frame, text)
+ local preview = frame._lsbInputPreview
+ if not preview then
+ return
+ end
+
+ text = text and tostring(text) or ""
+ preview:SetText(text)
+ if text ~= "" then
+ preview:Show()
+ else
+ preview:Hide()
+ end
+end
+
+local function cancelInputPreviewTimer(frame)
+ local timer = frame and frame._lsbInputPreviewTimer
+ if timer and timer.Cancel then
+ timer:Cancel()
+ end
+ if frame then
+ frame._lsbInputPreviewTimer = nil
+ end
+end
+
+local function syncInputRowText(frame, value)
+ local editBox = frame and frame._lsbInputEditBox
+ if not editBox then
+ return
+ end
+
+ value = value == nil and "" or tostring(value)
+ if editBox.GetText and editBox:GetText() == value then
+ return
+ end
+
+ frame._lsbUpdatingInputText = true
+ editBox:SetText(value)
+ frame._lsbUpdatingInputText = nil
+end
+
+local function resolveInputPreview(frame)
+ local data = frame and frame._lsbInputData
+ local setting = frame and frame._lsbInputSetting
+ if not data or not data.resolveText then
+ setInputPreviewText(frame, nil)
+ return
+ end
+
+ local value = setting and setting.GetValue and setting:GetValue() or nil
+ setInputPreviewText(frame, data.resolveText(value, setting, frame))
+end
+
+local function scheduleInputPreview(frame, immediate)
+ cancelInputPreviewTimer(frame)
+
+ local data = frame and frame._lsbInputData
+ if not data or not data.resolveText then
+ setInputPreviewText(frame, nil)
+ return
+ end
+
+ local delay = immediate and 0 or (data.debounce or 0)
+ if delay > 0 and C_Timer and C_Timer.NewTimer then
+ frame._lsbInputPreviewTimer = C_Timer.NewTimer(delay, function()
+ frame._lsbInputPreviewTimer = nil
+ resolveInputPreview(frame)
+ end)
+ return
+ end
+
+ resolveInputPreview(frame)
+end
+
+local function applyInputRowEnabledState(frame, enabled)
+ if not frame then
+ return
+ end
+
+ if frame.SetAlpha then
+ frame:SetAlpha(enabled and 1 or 0.5)
+ end
+
+ local editBox = frame._lsbInputEditBox
+ if not editBox then
+ return
+ end
+
+ if editBox.SetEnabled then
+ editBox:SetEnabled(enabled)
+ end
+ if editBox.EnableMouse then
+ editBox:EnableMouse(enabled)
+ end
+end
+
+local function applyInputRowFrame(frame, data)
+ local title, editBox, preview = ensureInputRowWidgets(frame)
+ local hasPreview = data.resolveText ~= nil
+
+ title:ClearAllPoints()
+ title:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, hasPreview and "TOPLEFT" or "LEFT", 37, hasPreview and -6 or 0)
+ title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
+ title:SetJustifyV(hasPreview and "TOP" or "MIDDLE")
+ title:SetText(data.name)
+ title:Show()
+
+ editBox:ClearAllPoints()
+ editBox:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, "CENTER", -80, hasPreview and -2 or 0)
+ editBox:SetSize(data.width or 140, 20)
+ if editBox.SetNumeric then
+ editBox:SetNumeric(data.numeric == true)
+ end
+ if editBox.SetMaxLetters and data.maxLetters then
+ editBox:SetMaxLetters(data.maxLetters)
+ end
+ if editBox.SetTextInsets then
+ editBox:SetTextInsets(6, 6, 0, 0)
+ end
+ editBox:Show()
+
+ preview:ClearAllPoints()
+ preview:SetPoint("TOPLEFT", editBox, "BOTTOMLEFT", 0, -3)
+ preview:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ if hasPreview then
+ preview:Show()
+ else
+ preview:Hide()
+ end
+
+ frame._lsbInputData = data
+ frame._lsbInputSetting = data.setting
+ editBox._lsbOwnerFrame = frame
+
+ if not editBox._lsbInputScriptsBound then
+ editBox:SetScript("OnTextChanged", function(self)
+ local owner = self._lsbOwnerFrame
+ if not owner or owner._lsbUpdatingInputText then
+ return
+ end
+
+ local setting = owner._lsbInputSetting
+ local text = self:GetText() or ""
+ if setting and setting.SetValue then
+ setting:SetValue(text)
+ end
+
+ local inputData = owner._lsbInputData
+ if inputData and inputData.onTextChanged then
+ inputData.onTextChanged(text, setting, owner)
+ end
+
+ scheduleInputPreview(owner, false)
+ end)
+ editBox:SetScript("OnEnterPressed", function(self)
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ end)
+ editBox:SetScript("OnEscapePressed", function(self)
+ local owner = self._lsbOwnerFrame
+ if owner then
+ local setting = owner._lsbInputSetting
+ local value = setting and setting.GetValue and setting:GetValue() or ""
+ syncInputRowText(owner, value)
+ scheduleInputPreview(owner, true)
+ end
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ end)
+ editBox._lsbInputScriptsBound = true
+ end
+
+ syncInputRowText(frame, data.setting and data.setting.GetValue and data.setting:GetValue() or "")
+
+ local ownVariable = data.settingVariable
+ registerValueChangedCallback(frame, ownVariable, function()
+ local currentSetting = frame._lsbInputSetting
+ local value = currentSetting and currentSetting.GetValue and currentSetting:GetValue() or ""
+ syncInputRowText(frame, value)
+ end, frame)
+
+ if data.watchVariables then
+ for _, variable in ipairs(data.watchVariables) do
+ if variable ~= ownVariable then
+ registerValueChangedCallback(frame, variable, function()
+ scheduleInputPreview(frame, true)
+ end, frame)
+ end
+ end
+ end
+
+ scheduleInputPreview(frame, true)
+end
+
local function applyEmbedCanvasFrame(frame, data, initializer)
local canvas = data.canvas
if not canvas then
@@ -303,6 +551,22 @@ local function initializerShouldShow(initializer)
return true
end
+local function initializerIsEnabled(initializer)
+ if initializer and initializer.EvaluateModifyPredicates then
+ return initializer:EvaluateModifyPredicates()
+ end
+
+ if initializer and initializer._modifyPredicates then
+ for _, predicate in ipairs(initializer._modifyPredicates) do
+ if not predicate() then
+ return false
+ end
+ end
+ end
+
+ return true
+end
+
local function createCustomListRowInitializer(template, data, extent, initFrame)
local initializer = Settings.CreateElementInitializer(template, data)
setInitializerExtent(initializer, extent)
@@ -325,6 +589,9 @@ local function createCustomListRowInitializer(template, data, extent, initFrame)
frame.EvaluateState = function(control)
local currentInitializer = control.GetElementData and control:GetElementData()
or control._lsbInitializer
+ if currentInitializer and currentInitializer.SetEnabled then
+ currentInitializer:SetEnabled(initializerIsEnabled(currentInitializer))
+ end
control:SetShown(initializerShouldShow(currentInitializer))
end
frame._lsbHasCustomEvaluateState = true
@@ -897,6 +1164,7 @@ function lib:New(config)
SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
+ SB.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
SB.CreateHeaderTitle = lib.CreateHeaderTitle
SB.CreateSubheaderTitle = lib.CreateSubheaderTitle
@@ -911,9 +1179,13 @@ function lib:New(config)
local adapter = config.pathAdapter
+ local function makeVarNameFromIdentifier(identifier)
+ return config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
+ end
+
local function makeVarName(spec)
local id = spec.key or spec.path
- return config.varPrefix .. "_" .. id:gsub("%.", "_")
+ return makeVarNameFromIdentifier(id)
end
local function resolveCategory(spec)
@@ -1011,6 +1283,7 @@ function lib:New(config)
setter
)
setting.SetValueNoCallback = setValueNoCallback
+ setting._lsbVariable = variable
return setting, cat
end
@@ -1075,6 +1348,16 @@ function lib:New(config)
slider = { min = true, max = true, step = true, formatter = true },
dropdown = { values = true, scrollHeight = true },
color = {},
+ input = {
+ debounce = true,
+ maxLetters = true,
+ numeric = true,
+ onTextChanged = true,
+ resolveText = true,
+ watch = true,
+ watchVariables = true,
+ width = true,
+ },
custom = { template = true, varType = true },
}
@@ -1437,6 +1720,79 @@ function lib:New(config)
return initializer, setting
end
+ function SB.Input(spec)
+ validateSpecFields("input", spec)
+
+ local setting, cat = makeProxySetting(spec, Settings.VarType.String, "")
+ local data = {
+ debounce = spec.debounce,
+ maxLetters = spec.maxLetters,
+ name = spec.name,
+ numeric = spec.numeric,
+ onTextChanged = spec.onTextChanged,
+ resolveText = spec.resolveText,
+ setting = setting,
+ settingVariable = getSettingVariable(setting),
+ tooltip = spec.tooltip,
+ width = spec.width,
+ }
+
+ local watchVariables = {}
+ if spec.watch then
+ for _, identifier in ipairs(spec.watch) do
+ watchVariables[#watchVariables + 1] = makeVarNameFromIdentifier(identifier)
+ end
+ end
+ if spec.watchVariables then
+ for _, variable in ipairs(spec.watchVariables) do
+ watchVariables[#watchVariables + 1] = variable
+ end
+ end
+ if #watchVariables > 0 then
+ data.watchVariables = watchVariables
+ end
+
+ local extent = spec.resolveText and 46 or 26
+ local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
+ local originalInitFrame = initializer.InitFrame
+ local originalResetter = initializer.Resetter
+
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ if controlInitializer._lsbActiveFrame then
+ applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled)
+ end
+ end
+
+ initializer.InitFrame = function(controlInitializer, frame)
+ controlInitializer._lsbActiveFrame = frame
+ originalInitFrame(controlInitializer, frame)
+ applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false)
+ end
+
+ initializer.Resetter = function(controlInitializer, frame)
+ cancelInputPreviewTimer(frame)
+ if frame and frame._lsbInputEditBox then
+ if frame._lsbInputEditBox.ClearFocus then
+ frame._lsbInputEditBox:ClearFocus()
+ end
+ frame._lsbInputEditBox._lsbOwnerFrame = nil
+ end
+ frame._lsbInputData = nil
+ frame._lsbInputSetting = nil
+ if controlInitializer._lsbActiveFrame == frame then
+ controlInitializer._lsbActiveFrame = nil
+ end
+ originalResetter(controlInitializer, frame)
+ end
+
+ Settings.RegisterInitializer(cat, initializer)
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
--- Creates a proxy setting backed by a custom frame template.
--- The template's Init receives initializer data containing {setting, name, tooltip}.
function SB.Custom(spec)
@@ -1463,6 +1819,7 @@ function lib:New(config)
slider = "Slider",
dropdown = "Dropdown",
color = "Color",
+ input = "Input",
custom = "Custom",
}
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index a1b220f7..4b23bb7f 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -6,7 +6,10 @@ It supports:
- path-based bindings for AceDB-style profile tables,
- handler-mode bindings for arbitrary storage,
+- built-in text input rows with optional debounced preview resolution,
- composite builders for common settings groups,
+- layout-only rows such as headers, subheaders, info rows, buttons, and embedded canvases,
+- XML/template-backed custom controls when a built-in row is not enough,
- canvas layout helpers for more complex pages,
- deterministic dropdown ordering,
- clickable slider value editing.
@@ -18,10 +21,12 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
| Need | LibSettingsBuilder |
|---|---|
| Standard settings pages | `RegisterFromTable(...)` |
-| Fine-grained control | imperative `SB.Checkbox(...)`, `SB.Slider(...)`, etc. |
+| Fine-grained control | imperative `SB.Checkbox(...)`, `SB.Slider(...)`, `SB.Input(...)`, etc. |
| Existing AceDB profiles | `PathAdapter(...)` |
| Custom storage | handler mode with `get` / `set` / `key` |
+| Text entry / numeric ID fields | `SB.Input(...)` or `type = "input"` |
| Reusable settings groups | border, font override, positioning composites |
+| XML-backed bespoke widgets | `SB.Custom(...)` |
| Custom settings pages | `CreateCanvasLayout(...)` |
## Quick start
@@ -71,6 +76,87 @@ SB.RegisterFromTable({
SB.RegisterCategories()
```
+## Supported `RegisterFromTable` types
+
+The table API understands both AceConfig-style aliases and library-specific row types.
+
+| Type | Meaning |
+|---|---|
+| `toggle` | Alias for a checkbox proxy setting |
+| `range` | Alias for a slider proxy setting |
+| `select` | Alias for a dropdown proxy setting |
+| `input` | Built-in text input row with optional preview / debounce support |
+| `color` | Color swatch proxy setting |
+| `execute` | Alias for a button row |
+| `header` | Blizzard-style section header |
+| `description` | Alias for a subheader row |
+| `info` | Left-label / right-value informational row |
+| `canvas` | Embedded frame row for canvas content |
+| `custom` | Proxy setting backed by a custom XML template |
+| `colorList` | Expands `defs` into multiple color swatches |
+| `toggleList` | Expands `defs` into multiple checkboxes |
+| `border` | Composite group for border enable / width / color |
+| `fontOverride` | Composite group for override toggle, font picker, and size slider |
+| `heightOverride` | Composite slider with nil/zero transforms |
+
+## Input rows
+
+`input` is the newest built-in control type. It is intended for cases where you want a normal settings row layout, but need text entry instead of a dropdown or slider.
+
+Supported `input` spec fields include the standard binding/modifier fields plus:
+
+- `numeric = true` — sets the edit box to numeric-only mode.
+- `maxLetters` — limits input length.
+- `width` — overrides the edit box width (default `140`).
+- `debounce` — delays preview refresh by N seconds.
+- `resolveText(value, setting, frame)` — returns the preview text shown under the edit box.
+- `watch = { ... }` — names/paths of sibling settings that should force the preview to refresh.
+- `watchVariables = { ... }` — direct proxy-setting variable names to watch.
+- `onTextChanged(text, setting, frame)` — optional hook fired after the new text is written.
+
+Example:
+
+```lua
+spellId = {
+ type = "input",
+ name = "Spell ID",
+ key = "draftSpellId",
+ numeric = true,
+ maxLetters = 10,
+ debounce = 1,
+ get = function()
+ return draft.spellIdText
+ end,
+ set = function(value)
+ draft.spellIdText = value or ""
+ end,
+ resolveText = function(value)
+ local id = tonumber(value)
+ return id and C_Spell.GetSpellName(id) or nil
+ end,
+}
+```
+
+## Implementation notes
+
+The library has three main implementation paths:
+
+- **Proxy controls** — `checkbox`, `slider`, `dropdown`, `color`, `input`, and `custom` all go through the same proxy-setting pipeline. That means path mode and handler mode work consistently across them.
+- **Layout rows** — `header`, `subheader`, `info`, `button`, and `canvas` are initializer/layout helpers rather than persisted settings.
+- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `toggleList` expand into multiple child controls.
+
+`input` specifically is implemented as a built-in custom list row using `SettingsListElementTemplate`, with an `InputBoxTemplate` edit box anchored in the standard left-label / right-control layout. It does **not** need a separate XML template the way `custom` controls do.
+
+Under the hood, an input row:
+
+1. creates a normal proxy setting via `Settings.RegisterProxySetting`,
+2. writes the current edit-box text back through that setting on `OnTextChanged`,
+3. optionally debounces preview work through `C_Timer.NewTimer`,
+4. refreshes the preview immediately when watched settings change via callback handles, and
+5. reuses the same enabled / hidden / parent modifier system as the other built-in controls.
+
+That keeps `input` aligned with the rest of the builder instead of turning it into a one-off control with different binding behavior.
+
## Documentation
- [Installation & Compatibility](docs/INSTALLATION.md)
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index da29aab5..1d776b9a 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -9,8 +9,18 @@ describe("LibSettingsBuilder", function()
local originalGlobals
local addonNS
local layoutUpdateCalls
+ local pendingTimers
local SB
+ local function runPendingTimers()
+ while #pendingTimers > 0 do
+ local timer = table.remove(pendingTimers, 1)
+ if not timer.cancelled and timer.callback then
+ timer.callback()
+ end
+ end
+ end
+
local function createSB2(varPrefix, categoryName)
local LSB2 = LibStub("LibSettingsBuilder-1.0")
local SB2 = LSB2:New({
@@ -90,8 +100,20 @@ describe("LibSettingsBuilder", function()
return self._scripts[event]
end
frame.SetAutoFocus = function() end
+ frame.SetEnabled = function(self, enabled)
+ self._enabled = enabled
+ end
+ frame.EnableMouse = function(self, enabled)
+ self._mouseEnabled = enabled
+ end
+ frame.SetMaxLetters = function(self, value)
+ self._maxLetters = value
+ end
frame.SetNumeric = function() end
frame.SetJustifyH = function() end
+ frame.SetJustifyV = function() end
+ frame.SetWordWrap = function() end
+ frame.SetTextInsets = function() end
frame.SetSize = function(self, width, height)
self:SetWidth(width)
self:SetHeight(height)
@@ -164,6 +186,7 @@ describe("LibSettingsBuilder", function()
"SettingsListElementMixin",
"SettingsDropdownControlMixin",
"SettingsSliderControlMixin",
+ "C_Timer",
"GameFontHighlight",
"GameFontHighlightSmall",
"GameFontNormal",
@@ -176,14 +199,30 @@ describe("LibSettingsBuilder", function()
before_each(function()
layoutUpdateCalls = 0
+ pendingTimers = {}
TestHelpers.SetupLibStub()
TestHelpers.SetupSettingsStubs()
+ _G.CreateFrame = function(_, _, _, template)
+ local frame = createScriptableFrame()
+ frame._template = template
+ return frame
+ end
_G.ECM_DeepEquals = TestHelpers.deepEquals
_G.GameFontHighlight = "GameFontHighlight"
_G.GameFontHighlightSmall = "GameFontHighlightSmall"
_G.GameFontNormal = "GameFontNormal"
+ _G.C_Timer = {
+ NewTimer = function(_, callback)
+ local timer = { callback = callback, cancelled = false }
+ function timer:Cancel()
+ self.cancelled = true
+ end
+ pendingTimers[#pendingTimers + 1] = timer
+ return timer
+ end,
+ }
_G.UnitClass = function()
return "Warrior", "WARRIOR", 1
@@ -615,6 +654,88 @@ describe("LibSettingsBuilder", function()
assert.are.equal(1, #init._shownPredicates)
end)
+ it("Input creates an input row initializer and writes string values", function()
+ local init, setting = SB.Input({
+ path = "global.font",
+ name = "Entry ID",
+ layout = false,
+ })
+
+ assert.are.equal(SB.INPUTROW_TEMPLATE, init._template)
+ assert.are.equal("Global Font", setting:GetValue())
+
+ setting:SetValue("12345")
+ assert.are.equal("12345", addonNS.Addon.db.profile.global.font)
+ end)
+
+ it("Input rows debounce preview text and refresh when watched settings change", function()
+ local currentKind = "spell"
+ local draftId = ""
+ local _, kindSetting = SB.Dropdown({
+ get = function()
+ return currentKind
+ end,
+ set = function(value)
+ currentKind = value
+ end,
+ key = "kind",
+ default = "spell",
+ name = "Kind",
+ values = { spell = "Spell", item = "Item" },
+ layout = false,
+ })
+
+ local inputInit = SB.Input({
+ get = function()
+ return draftId
+ end,
+ set = function(value)
+ draftId = value
+ end,
+ key = "draftId",
+ default = "",
+ name = "Entry ID",
+ debounce = 1,
+ layout = false,
+ resolveText = function(text)
+ if not text or text == "" then
+ return nil
+ end
+ return currentKind .. ":" .. text
+ end,
+ watch = { "kind" },
+ })
+
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetJustifyH = function() end
+ fontString.SetJustifyV = function() end
+ fontString.SetWordWrap = function() end
+ return fontString
+ end
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ inputInit:InitFrame(frame)
+
+ local editBox = frame._lsbInputEditBox
+ editBox:SetText("123")
+ editBox:GetScript("OnTextChanged")(editBox)
+
+ assert.are.equal("123", draftId)
+ assert.are.equal("", frame._lsbInputPreview:GetText())
+
+ runPendingTimers()
+ assert.are.equal("spell:123", frame._lsbInputPreview:GetText())
+
+ kindSetting:SetValue("item")
+ assert.are.equal("item:123", frame._lsbInputPreview:GetText())
+ end)
+
it("custom list rows initialize safely without preexisting cbrHandles", function()
local function makeListElementFrame()
local frame = createScriptableFrame()
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index c3d12c8c..3c155978 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -75,6 +75,7 @@ Common spec fields:
- `getTransform`
- `setTransform`
- `onSet`
+- `layout`
### `SB.Checkbox(spec)`
@@ -104,6 +105,29 @@ Dropdown values are emitted in deterministic order to keep menus stable between
Reads and writes `{ r, g, b, a }` tables through a hex proxy value.
+### `SB.Input(spec)`
+
+Creates a text input row using the standard settings-row layout.
+
+Additional fields:
+
+- `numeric`
+- `maxLetters`
+- `width`
+- `debounce`
+- `resolveText(value, setting, frame)`
+- `watch`
+- `watchVariables`
+- `onTextChanged(text, setting, frame)`
+
+Notes:
+
+- the edit box writes through the same proxy-setting pipeline as the other built-in controls,
+- `resolveText` enables an optional preview line below the edit box,
+- `debounce` delays preview recomputation through `C_Timer.NewTimer`,
+- `watch` accepts sibling spec identifiers and resolves them to this builder's proxy-setting variables,
+- `watchVariables` accepts already-resolved proxy-setting variable names.
+
### `SB.Custom(spec)`
Additional fields:
@@ -111,6 +135,12 @@ Additional fields:
- `template`
- `varType`
+Notes:
+
+- use this for XML-backed widgets that are not covered by the built-in controls,
+- the template must already be loaded by the time you register settings,
+- unlike `input`, `custom` does not create its frame structure in Lua.
+
### `SB.Control(spec)`
Dispatches to the correct control factory using `spec.type`.
@@ -122,7 +152,6 @@ Dispatches to the correct control factory using `spec.type`.
- `SB.BorderGroup(borderPath[, spec])`
- `SB.ColorPickerList(basePath, defs[, spec])`
- `SB.CheckboxList(basePath, defs[, spec])`
-- `SB.PositioningGroup(configPath, spec)`
## Utility helpers
@@ -144,6 +173,7 @@ Supported standard types:
- `checkbox` / `toggle`
- `slider` / `range`
- `dropdown` / `select`
+- `input`
- `color`
- `custom`
- `button` / `execute`
@@ -154,13 +184,22 @@ Supported standard types:
Supported composite types:
-- `positioning`
- `border`
- `fontOverride`
- `heightOverride`
- `colorList`
- `toggleList`
+## Implementation model
+
+The library has three main families of row builders:
+
+- **proxy controls** — persisted values backed by `Settings.RegisterProxySetting` (`checkbox`, `slider`, `dropdown`, `color`, `input`, `custom`),
+- **layout rows** — structural/display rows without stored values (`header`, `subheader`, `info`, `button`, `canvas`),
+- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `toggleList`).
+
+`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template.
+
## Canvas layout helpers
`CreateCanvasLayout` returns a layout object with these methods:
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index 01fb23c2..388dee3e 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -48,8 +48,26 @@ The library integrates with Blizzard's Settings UI and installs a few global hoo
- scrollable dropdowns,
- clickable slider value editing.
+When you use `input` rows with `debounce` / `resolveText`, the library also uses callback handles and `C_Timer.NewTimer` to keep previews in sync.
+
Those hooks are part of the library's behavior and should be considered when debugging conflicts with heavily customized Settings UI code.
+## Built-in controls vs custom templates
+
+Most library features are available with no extra XML:
+
+- proxy controls like `checkbox`, `slider`, `dropdown`, `color`, and `input`,
+- layout rows like `header`, `subheader`, `info`, `button`, and `canvas`,
+- composite builders like `border`, `fontOverride`, and `heightOverride`.
+
+`input` is a built-in row type implemented entirely in Lua on top of `SettingsListElementTemplate` plus a runtime-created `InputBoxTemplate` edit box.
+
+Only `SB.Custom(...)` requires you to supply your own template. In that case:
+
+1. define the template in XML,
+2. load that XML from your TOC before registering categories, and
+3. pass the template name through `spec.template`.
+
## Canvas layout compatibility
Canvas layout spacing defaults are modeled after Blizzard's retail Settings panel measurements and can be adjusted when needed:
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index 3858f132..4057a0e2 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -26,6 +26,7 @@
| `RegisterOptionsTable` | `SB.RegisterFromTable` |
| `AddToBlizOptions` | `SB.RegisterCategories()` |
| one `get`/`set` per field | one `path` per field in path mode |
+| `type = "input"` | `type = "input"` or `SB.Input(...)` |
| custom refresh dance | reactive modifiers re-evaluate automatically |
## Path mode replaces repeated getters and setters
@@ -54,6 +55,7 @@ local SB = LSB:New({
- `toggle` → `checkbox`
- `range` → `slider`
- `select` → `dropdown`
+- `input` → `input`
- `execute` → `button`
- `description` → `subheader`
- `desc` → `tooltip`
@@ -63,13 +65,30 @@ local SB = LSB:New({
- native Blizzard Settings integration,
- composite builders for common UI groups,
- canvas layout helpers for complex pages,
+- built-in text input rows with optional debounced previews,
- clickable slider value editing,
- deterministic dropdown ordering.
## Features you still build yourself
-- custom input widgets,
- specialized row templates,
- bespoke canvas pages.
+If you only need text or numeric entry, use the built-in `input` type first. Reach for `SB.Custom(...)` only when you need a genuinely different widget.
+
Use `SB.Custom(...)` or `CreateCanvasLayout(...)` when the standard controls stop fitting.
+
+## Migrating AceConfig input fields
+
+Simple AceConfig `input` fields usually map directly:
+
+```lua
+search = {
+ type = "input",
+ path = "searchText",
+ name = "Search",
+ order = 10,
+}
+```
+
+If your old AceConfig input also computed helper text or validity hints, move that into `resolveText(...)` and optionally add `debounce` to avoid recomputing on every keystroke.
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index d22e0fc1..4996da4b 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -13,6 +13,7 @@
- Use **table-driven registration** if you want the shortest path to a normal settings page.
- Use the **imperative API** if you want precise control over layout and call order.
- Use **handler mode** if your settings are not stored in a dot-path table.
+- Use `input` rows when you need text or numeric entry without building a custom template.
## Table-driven setup
@@ -56,6 +57,19 @@ SB.RegisterFromTable({
step = 1,
order = 2,
},
+ spellId = {
+ type = "input",
+ path = "spellIdText",
+ name = "Spell ID",
+ numeric = true,
+ maxLetters = 10,
+ debounce = 1,
+ order = 3,
+ resolveText = function(value)
+ local id = tonumber(value)
+ return id and C_Spell.GetSpellName(id) or nil
+ end,
+ },
},
})
@@ -82,9 +96,22 @@ SB.Slider({
step = 1,
})
+SB.Input({
+ path = "general.spellIdText",
+ name = "Spell ID",
+ numeric = true,
+ debounce = 1,
+ resolveText = function(value)
+ local id = tonumber(value)
+ return id and C_Spell.GetSpellName(id) or nil
+ end,
+})
+
SB.RegisterCategories()
```
+`RegisterFromTable(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `toggle`, `range`, `input`, `header`, `description`, `info`, `button`, and `canvas` entries on one page.
+
## Handler mode
```lua
@@ -110,6 +137,18 @@ SB.Checkbox({
name = "Enable",
})
+SB.Input({
+ get = function()
+ return MyStore.searchText or ""
+ end,
+ set = function(value)
+ MyStore.searchText = value
+ end,
+ key = "searchText",
+ default = "",
+ name = "Search",
+})
+
SB.RegisterCategories()
```
@@ -120,3 +159,4 @@ SB.RegisterCategories()
- Keep `onChanged` fast; use it to refresh UI, not rebuild the world.
- Use composites for repeated patterns like borders, font overrides, and positioning.
- Prefer table-driven registration for large standard settings pages.
+- Reach for `SB.Custom(...)` only when built-ins like `input` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index 5f980968..52a6ed1b 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -30,7 +30,8 @@ Usually one of these:
- you forgot `SB.RegisterCategories()`,
- you created a subcategory but never added controls to it,
-- a `hidden` predicate is always returning `true`.
+- a `hidden` predicate is always returning `true`,
+- a `custom` template was never loaded from XML.
## A child control is always disabled or hidden
@@ -42,6 +43,18 @@ Check modifier predicates:
Remember these are reactive and will be re-evaluated after setting changes.
+## Input preview does not refresh
+
+Check these pieces:
+
+- `resolveText(...)` must return a string or `nil`,
+- `debounce` delays preview updates intentionally,
+- `watch = { ... }` uses sibling spec identifiers / paths, not display labels,
+- `watchVariables` expects already-resolved proxy-setting variable names,
+- watched settings must come from the same builder instance so callback handles can observe them.
+
+If you just need raw text entry with no secondary preview, omit `resolveText` entirely.
+
## Dropdown options look wrong
`values` can be a table or a function returning a table.
@@ -54,6 +67,19 @@ Recommendations:
Dropdown entries are ordered deterministically by label, then by value, to avoid random menu ordering between sessions.
+## Custom template control never initializes
+
+Built-in rows like `checkbox`, `slider`, `dropdown`, `color`, and `input` do not need extra XML.
+
+`custom` controls do.
+
+If a custom control appears blank or never receives its initializer data:
+
+- verify the XML file defining the template is loaded from your TOC,
+- verify the template name passed in `spec.template` matches the XML definition,
+- verify the template inherits the correct Blizzard settings row template for your widget,
+- verify no addon is replacing the Settings initialization pipeline.
+
## Slider value editing does not behave as expected
The library adds inline numeric editing to slider value labels.
diff --git a/Locales/en.lua b/Locales/en.lua
index 06617337..18a3a281 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -234,6 +234,10 @@ L["EXTRA_ICONS_RESET_CONFIRM"] = "Reset extra icons to defaults?"
L["ADD_NEW_HEADER"] = "Add New"
L["ENTRY_TYPE"] = "Type"
L["ENTRY_VIEWER"] = "Viewer"
+L["ENTRY_ID"] = "ID"
+L["UTILITY_VIEWER"] = "Utility"
+L["MAIN_VIEWER"] = "Main"
+L["EXTRA_ICONS_ITEM_LOADING"] = "Loading item..."
L["ADD_ENTRY"] = "Add"
L["PRESETS_HEADER"] = "Quick Add"
L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
diff --git a/README.md b/README.md
index d35a82e1..1bba3287 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Enhanced Cooldown Manager by Argium
-Enhanced Cooldown Manager creates a clean combat HUD around Blizzard's built-in cooldown manager that **looks and works great out of the box** and is **straightforward to customise.**
+Enhanced Cooldown Manager creates a clean combat HUD around Blizzard's built-in cooldown manager that **looks and works great out of the box** and is **straightforward to customise.** It adds a mana/power bar and resource bar, extra icons for potions, and supports per-spell color and customisation for aura bars. Modular design allow each part to be attached to the CDM or detached and freely placed.
-Made with ❤️, with little features you didn't know you needed and won't want to live without.
+Made with ❤️, with little features you won't want to live without.
## Features
@@ -68,30 +68,6 @@ Use the layout mode that fits your setup.
- Use `/ecm` in game to open options.
- You can also open it from the AddOn compartment menu near the minimap.
-## Module support by class
-
-Legend: 🟢 supported
-
-| Class | Power Bar | Resource Bar |
-| --- | --- | --- |
-| Death Knight | 🟢 | 🟢 Runes |
-| Demon Hunter | 🟢 | 🟢 Vengeance (Soul Fragments)
🟢 Devourer (Void Fragments) |
-| Druid | 🟢 Balance/Restoration (Mana)
🟢 Feral (Energy)
🟢 Guardian (Rage) | 🟢 Feral (Combo Points) |
-| Evoker | 🟢 Preservation (Mana) | 🟢 Essence |
-| Hunter | 🟢 | |
-| Mage | 🟢 | 🟢 Arcane (Charges), Frost (Icicles) |
-| Monk | 🟢 Mistweaver (Mana)
🟢 Brewmaster (Energy)
🟢 Windwalker (Energy) | 🟢 Windwalker (Chi) |
-| Paladin | 🟢 Holy (Mana) | 🟢 Holy Power|
-| Priest | 🟢 | |
-| Rogue | 🟢 | 🟢 Combo points|
-| Shaman | 🟢 | 🟢 Enhancement (Maelstrom Weap.) |
-| Warlock | 🟢 | 🟢 Soul shards |
-| Warrior | 🟢 | |
-
-## Troubleshooting
-
-If you run into a problem, enable debug tracing with the command `/ecm debug on` and reload your UI. Include any output when reporting an issue.
-
## License
[GPL-3.0](LICENSE)
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index ccd02bef..047eda49 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -100,17 +100,30 @@ local function makeInitializer(setting)
end
--- Create a minimal stub setting returned by Settings.RegisterProxySetting.
-local function makeSetting(getter, setter, default, name)
- return {
- GetValue = function()
- return getter()
- end,
- SetValue = function(_, value)
- setter(value)
- end,
+local function makeSetting(getter, setter, default, name, variable)
+ local setting = {
_default = default,
+ _lsbCallbacks = {},
+ _lsbVariable = variable,
_name = name,
}
+
+ function setting:GetValue()
+ return getter()
+ end
+
+ function setting:_lsbNotifyValueChanged(value)
+ for _, handle in ipairs(self._lsbCallbacks) do
+ handle.callback(handle.owner or self, value, self)
+ end
+ end
+
+ function setting:SetValue(value)
+ setter(value)
+ self:_lsbNotifyValueChanged(self:GetValue())
+ end
+
+ return setting
end
--- Setup a minimal LibStub stub for tests.
@@ -202,6 +215,8 @@ function TestHelpers.SetupSettingsStubs()
return layout
end
+ local proxySettingsByVariable = {}
+
_G.Settings = {
VarType = { Boolean = "boolean", Number = "number", String = "string" },
@@ -267,8 +282,10 @@ function TestHelpers.SetupSettingsStubs()
return init
end,
- RegisterProxySetting = function(_, _, _, name, default, getter, setter)
- return makeSetting(getter, setter, default, name)
+ RegisterProxySetting = function(_, variable, _, name, default, getter, setter)
+ local setting = makeSetting(getter, setter, default, name, variable)
+ proxySettingsByVariable[variable] = setting
+ return setting
end,
CreateCheckbox = function(cat, setting)
@@ -307,9 +324,25 @@ function TestHelpers.SetupSettingsStubs()
self._handles[#self._handles + 1] = handle
end,
SetOnValueChangedCallback = function(self, variable, callback, owner)
- self:AddHandle({ variable = variable, callback = callback, owner = owner })
+ local handle = { variable = variable, callback = callback, owner = owner }
+ self:AddHandle(handle)
+ local setting = proxySettingsByVariable[variable]
+ if setting then
+ handle.setting = setting
+ setting._lsbCallbacks[#setting._lsbCallbacks + 1] = handle
+ end
end,
Unregister = function(self)
+ for _, handle in ipairs(self._handles) do
+ local setting = handle.setting
+ if setting and setting._lsbCallbacks then
+ for i = #setting._lsbCallbacks, 1, -1 do
+ if setting._lsbCallbacks[i] == handle then
+ table.remove(setting._lsbCallbacks, i)
+ end
+ end
+ end
+ end
self._handles = {}
self._unregistered = true
end,
@@ -809,6 +842,7 @@ TestHelpers.OPTIONS_GLOBALS = {
"canaccessvalue",
"canaccesstable",
"time",
+ "C_Timer",
"C_PartyInfo",
"IsInInstance",
"GetInventoryItemTexture",
@@ -867,6 +901,13 @@ function TestHelpers.SetupOptionsGlobals()
GetItemIconByID = function()
return nil
end,
+ GetItemNameByID = function()
+ return nil
+ end,
+ DoesItemExistByID = function()
+ return true
+ end,
+ RequestLoadItemDataByID = function() end,
}
_G.C_Spell = {
GetSpellName = function()
@@ -885,6 +926,21 @@ function TestHelpers.SetupOptionsGlobals()
_G.time = function()
return 1000
end
+ local pendingTimers = {}
+ TestHelpers._pendingCTimers = pendingTimers
+ _G.C_Timer = {
+ After = function(delay, callback)
+ pendingTimers[#pendingTimers + 1] = { delay = delay, callback = callback }
+ end,
+ NewTimer = function(delay, callback)
+ local timer = { delay = delay, callback = callback, cancelled = false }
+ function timer:Cancel()
+ self.cancelled = true
+ end
+ pendingTimers[#pendingTimers + 1] = timer
+ return timer
+ end,
+ }
_G.C_AddOns = {
GetAddOnMetadata = function()
return nil
@@ -1147,6 +1203,23 @@ function TestHelpers.SetupOptionsGlobals()
}
end
+function TestHelpers.RunNextTimer()
+ local pending = TestHelpers._pendingCTimers or {}
+ while #pending > 0 do
+ local timer = table.remove(pending, 1)
+ if not timer.cancelled and timer.callback then
+ timer.callback()
+ return true
+ end
+ end
+ return false
+end
+
+function TestHelpers.RunAllTimers()
+ while TestHelpers.RunNextTimer() do
+ end
+end
+
--- Load LibSettingsBuilder and register the shared LibLSMSettingsWidgets test stub.
function TestHelpers.SetupLibSettingsBuilder()
TestHelpers.LoadChunk("Libs/LibSettingsBuilder/LibSettingsBuilder.lua", "Unable to load LibSettingsBuilder.lua")()
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 0745c22f..a6a9fc10 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -269,6 +269,63 @@ describe("ExtraIconsOptions data helpers", function()
end)
end)
+ describe("_parseSingleId", function()
+ it("parses a single integer ID", function()
+ assert.are.equal(12345, ExtraIconsOptions._parseSingleId("12345"))
+ end)
+
+ it("returns nil for empty or invalid input", function()
+ assert.is_nil(ExtraIconsOptions._parseSingleId(""))
+ assert.is_nil(ExtraIconsOptions._parseSingleId("abc"))
+ assert.is_nil(ExtraIconsOptions._parseSingleId("1.5"))
+ assert.is_nil(ExtraIconsOptions._parseSingleId("-4"))
+ end)
+ end)
+
+ describe("_resolveDraftEntryName", function()
+ local savedCSpell, savedCItem
+
+ before_each(function()
+ savedCSpell = _G.C_Spell
+ savedCItem = _G.C_Item
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ }
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId ~= 99999
+ end,
+ GetItemNameByID = function(itemId)
+ return itemId == 777 and "Test Item" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+ end)
+
+ after_each(function()
+ _G.C_Spell = savedCSpell
+ _G.C_Item = savedCItem
+ end)
+
+ it("resolves spell names for valid spell IDs", function()
+ assert.are.equal("Test Spell", ExtraIconsOptions._resolveDraftEntryName("spell", "12345"))
+ end)
+
+ it("returns nil for invalid spell IDs", function()
+ assert.is_nil(ExtraIconsOptions._resolveDraftEntryName("spell", "99999"))
+ end)
+
+ it("returns loading text for valid uncached items", function()
+ assert.are.equal(ns.L["EXTRA_ICONS_ITEM_LOADING"], ExtraIconsOptions._resolveDraftEntryName("item", "12345"))
+ end)
+
+ it("returns nil for invalid items", function()
+ assert.is_nil(ExtraIconsOptions._resolveDraftEntryName("item", "99999"))
+ end)
+ end)
+
describe("_otherViewer", function()
it("utility returns main", function()
assert.are.equal("main", ExtraIconsOptions._otherViewer("utility"))
@@ -446,22 +503,74 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], vc._viewerHeaders.main._title:GetText())
end)
- it("registers native quick-add buttons and no custom add form fields", function()
+ it("registers the native add form above the viewer canvas", function()
assert.is_not_nil(capturedTable)
- assert.is_nil(capturedTable.args.addHeader)
- assert.is_nil(capturedTable.args.addType)
- assert.is_nil(capturedTable.args.addViewer)
- assert.is_nil(capturedTable.args.addForm)
- assert.is_nil(capturedTable.args.defaults)
+ assert.are.equal("header", capturedTable.args.addHeader.type)
+ assert.are.equal("select", capturedTable.args.addType.type)
+ assert.are.equal("select", capturedTable.args.addViewer.type)
+ assert.are.equal("input", capturedTable.args.addId.type)
+ assert.are.equal("button", capturedTable.args.addEntry.type)
assert.is_not_nil(capturedTable.args.quickAdd_trinket1)
assert.are.equal("button", capturedTable.args.quickAdd_trinket1.type)
assert.are.equal("button", capturedTable.args.quickAddRacial.type)
assert.are.equal("canvas", capturedTable.args.viewers.type)
+ assert.is_true(capturedTable.args.addHeader.order < capturedTable.args.viewers.order)
+ assert.is_true(capturedTable.args.addType.order < capturedTable.args.viewers.order)
+ assert.is_true(capturedTable.args.addViewer.order < capturedTable.args.viewers.order)
+ assert.is_true(capturedTable.args.addId.order < capturedTable.args.viewers.order)
+ assert.is_true(capturedTable.args.addEntry.order < capturedTable.args.viewers.order)
assert.is_true(capturedTable.args.viewers.order < capturedTable.args.presetsHeader.order)
assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAdd_trinket1.order)
assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAddRacial.order)
end)
+ it("wires the add form to ephemeral draft state and single-ID preview resolution", function()
+ local opts = ns.ExtraIconsOptions
+
+ assert.are.equal("spell", capturedTable.args.addType.get())
+ assert.are.equal("utility", capturedTable.args.addViewer.get())
+ assert.are.equal("", capturedTable.args.addId.get())
+ assert.is_true(capturedTable.args.addEntry.disabled())
+
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+
+ capturedTable.args.addId.set("12345")
+
+ assert.are.equal("12345", opts._formState.idText)
+ assert.are.equal("Test Spell", capturedTable.args.addId.resolveText("12345"))
+ assert.is_false(capturedTable.args.addEntry.disabled())
+ end)
+
+ it("adds a custom spell entry and clears the draft ID", function()
+ local opts = ns.ExtraIconsOptions
+
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+
+ capturedTable.args.addType.set("spell")
+ capturedTable.args.addViewer.set("main")
+ capturedTable.args.addId.set("12345")
+ capturedTable.args.addEntry.onClick()
+
+ assert.are.equal("", opts._formState.idText)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
+ assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
+ end)
+
it("hides the quick-add heading when no quick-add entries are visible", function()
local viewers = profile.extraIcons.viewers
local racial = ns.Constants.RACIAL_ABILITIES.Human
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index e5a9325e..be1419db 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -175,6 +175,54 @@ function ExtraIconsOptions._parseIds(text)
return #ids > 0 and ids or nil
end
+--- Parse a single positive integer ID from a string.
+---@param text string|nil
+---@return number|nil
+function ExtraIconsOptions._parseSingleId(text)
+ if not text or text == "" then
+ return nil
+ end
+
+ local num = tonumber(text)
+ if not num or num <= 0 or num ~= math.floor(num) then
+ return nil
+ end
+
+ return num
+end
+
+--- Resolve a draft spell or item ID to a display name.
+---@param kind string
+---@param text string|nil
+---@return string|nil
+function ExtraIconsOptions._resolveDraftEntryName(kind, text)
+ local id = ExtraIconsOptions._parseSingleId(text)
+ if not id then
+ return nil
+ end
+
+ if kind == "spell" then
+ return C_Spell.GetSpellName(id)
+ end
+
+ if kind == "item" then
+ if not C_Item.DoesItemExistByID(id) then
+ return nil
+ end
+
+ local name = C_Item.GetItemNameByID(id)
+ if name then
+ return name
+ end
+
+ C_Item.RequestLoadItemDataByID(id)
+
+ return L["EXTRA_ICONS_ITEM_LOADING"]
+ end
+
+ return nil
+end
+
--- Get the opposite viewer key.
function ExtraIconsOptions._otherViewer(viewerKey)
return viewerKey == "utility" and "main" or "utility"
@@ -343,10 +391,46 @@ function ExtraIconsOptions.RegisterSettings(SB)
return getProfile().extraIcons.viewers
end
+ ExtraIconsOptions._formState = ExtraIconsOptions._formState or {
+ kind = "spell",
+ viewer = "utility",
+ idText = "",
+ }
+ local formState = ExtraIconsOptions._formState
+
local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
+ local function getResolvedDraftName()
+ return ExtraIconsOptions._resolveDraftEntryName(formState.kind, formState.idText)
+ end
+
+ local function canAddDraftEntry()
+ local id = ExtraIconsOptions._parseSingleId(formState.idText)
+ if not id then
+ return false
+ end
+
+ if formState.kind == "spell" then
+ return getResolvedDraftName() ~= nil
+ end
+
+ return true
+ end
+
+ local function addDraftEntry()
+ local id = ExtraIconsOptions._parseSingleId(formState.idText)
+ if not id or not canAddDraftEntry() then
+ return
+ end
+
+ ExtraIconsOptions._addCustomEntry(getProfile(), formState.viewer, formState.kind, { id })
+ formState.idText = ""
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end
+
local function getPlayerRacialSpellId()
local _, raceFile = UnitRace("player")
local racial = raceFile and RACIAL_ABILITIES[raceFile]
@@ -522,6 +606,15 @@ function ExtraIconsOptions.RegisterSettings(SB)
--------------------------------------------------------------------
-- Register via table
--------------------------------------------------------------------
+ local addTypeValues = {
+ spell = L["ADD_SPELL"],
+ item = L["ADD_ITEM"],
+ }
+ local addViewerValues = {
+ utility = L["UTILITY_VIEWER"],
+ main = L["MAIN_VIEWER"],
+ }
+
local args = {
enabled = {
type = "toggle",
@@ -534,11 +627,78 @@ function ExtraIconsOptions.RegisterSettings(SB)
handler(value)
end,
},
+ addHeader = {
+ type = "header",
+ name = L["ADD_NEW_HEADER"],
+ order = 10,
+ },
+ addType = {
+ type = "select",
+ name = L["ENTRY_TYPE"],
+ values = addTypeValues,
+ key = "addType",
+ default = formState.kind,
+ layout = false,
+ order = 11,
+ disabled = isDisabled,
+ get = function()
+ return formState.kind
+ end,
+ set = function(value)
+ formState.kind = value
+ end,
+ },
+ addViewer = {
+ type = "select",
+ name = L["ENTRY_VIEWER"],
+ values = addViewerValues,
+ key = "addViewer",
+ default = formState.viewer,
+ layout = false,
+ order = 12,
+ disabled = isDisabled,
+ get = function()
+ return formState.viewer
+ end,
+ set = function(value)
+ formState.viewer = value
+ end,
+ },
+ addId = {
+ type = "input",
+ name = L["ENTRY_ID"],
+ key = "addId",
+ default = formState.idText,
+ debounce = 1,
+ disabled = isDisabled,
+ layout = false,
+ maxLetters = 10,
+ numeric = true,
+ order = 13,
+ watch = { "addType" },
+ get = function()
+ return formState.idText
+ end,
+ set = function(value)
+ formState.idText = value or ""
+ end,
+ resolveText = function(text)
+ return ExtraIconsOptions._resolveDraftEntryName(formState.kind, text)
+ end,
+ },
+ addEntry = {
+ type = "button",
+ name = L["ADD_ENTRY"],
+ buttonText = L["ADD_ENTRY"],
+ disabled = function()
+ return isDisabled() or not canAddDraftEntry()
+ end,
+ layout = false,
+ onClick = addDraftEntry,
+ order = 14,
+ },
}
- -- Custom spell/item entry form intentionally omitted here.
- -- LibSettingsBuilder does not provide a native text input control, and this
- -- page must not recreate one with a canvas embed.
for i, stackKey in ipairs(BUILTIN_STACK_ORDER) do
local stack = BUILTIN_STACKS[stackKey]
args["quickAdd_" .. stackKey] = {
@@ -557,7 +717,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
end
local racialSpellId = getPlayerRacialSpellId()
- local racialName = racialSpellId and C_Spell and C_Spell.GetSpellName and C_Spell.GetSpellName(racialSpellId) or nil
+ local racialName = racialSpellId and C_Spell.GetSpellName(racialSpellId) or nil
args.quickAddRacial = {
type = "button",
name = racialName or "Racial",
From 3c88ede30de58a709c06aeb63296ecfe33ed35b3 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 8 Apr 2026 22:16:41 +1000
Subject: [PATCH 05/53] Redesign extra icons options panel
---
ARCHITECTURE.md | 6 +-
Defaults.lua | 7 +
.../LibSettingsBuilder/LibSettingsBuilder.lua | 18 +-
.../Tests/LibSettingsBuilder_spec.lua | 44 +
Locales/en.lua | 8 +-
Modules/ExtraIcons.lua | 47 +-
Tests/Modules/ExtraIcons_spec.lua | 84 +-
Tests/TestHelpers.lua | 54 +-
Tests/UI/ExtraIconsOptions_spec.lua | 497 ++++++++--
UI/ExtraIconsOptions.lua | 923 ++++++++++++------
10 files changed, 1250 insertions(+), 438 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 7159094b..e1a5e64c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -363,7 +363,7 @@ Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Use
| `item` | `ids[]` (priority stack) | First with `C_Item.GetItemCount > 0` | `C_Item.GetItemCooldown` |
| `spell` | `ids[]` (priority stack) | First with `IsPlayerSpell` → `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
-Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Custom and racial entries store fields directly in saved config.
+Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Built-in entries may also persist `disabled = true`, which keeps them in the settings list but skips them during runtime resolution. Custom and racial entries store fields directly in saved config.
**Config Structure (`profile.extraIcons`):**
@@ -373,7 +373,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
viewers = {
utility = { -- ordered array
{ stackKey = "trinket1" }, -- resolved from BUILTIN_STACKS
- { stackKey = "trinket2" },
+ { stackKey = "trinket2", disabled = true },
{ stackKey = "combatPotions" },
{ kind = "spell", ids = { 59752 } }, -- racial (self-contained)
},
@@ -382,7 +382,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Quick-add buttons register absent predefined stacks and the current racial; per-row controls handle reorder (↑↓), move between viewers (→←), and delete (✕).
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Built-in rows use the trailing button as an enable/disable toggle instead of removal, and missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section. The current-player racial is also synthesized as a disabled placeholder when absent; adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
### FrameUtil (`ns.FrameUtil`)
diff --git a/Defaults.lua b/Defaults.lua
index 580c728d..bed27b62 100644
--- a/Defaults.lua
+++ b/Defaults.lua
@@ -99,6 +99,13 @@ local _, ns = ...
---@field fontSize number|nil Font size override for aura bar text.
---@field colors ECM_SpellColorsConfig Per-spell color settings.
+---@class ECM_ExtraIconEntry
+---@field stackKey string|nil Built-in stack key resolved via `BUILTIN_STACKS`.
+---@field kind string|nil Entry kind for custom or racial rows.
+---@field ids table|nil Entry spell/item priority list.
+---@field slotId number|nil Slot ID for equip-slot entries.
+---@field disabled boolean|nil When true, the entry stays in settings but is skipped at runtime.
+
---@class ECM_ExtraIconsConfig Extra icons configuration.
---@field enabled boolean Whether extra icons are enabled.
---@field viewers table Per-viewer ordered icon lists.
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index 9ec2f990..f36d46ab 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -2025,8 +2025,19 @@ function lib:New(config)
-- Utility helpers
----------------------------------------------------------------------------
- function SB.Header(text, category)
- local cat = category or SB._currentSubcategory or SB._rootCategory
+ function SB.Header(textOrSpec, category)
+ local spec
+ if type(textOrSpec) == "table" then
+ spec = textOrSpec
+ else
+ spec = {
+ name = textOrSpec,
+ category = category,
+ }
+ end
+
+ local cat = resolveCategory(spec)
+ local text = spec.name
if not SB._firstHeaderAdded[cat] then
SB._firstHeaderAdded[cat] = true
@@ -2039,6 +2050,7 @@ function lib:New(config)
local layout = SB._layouts[cat]
local initializer = CreateSettingsListSectionHeaderInitializer(text)
layout:AddInitializer(initializer)
+ applyModifiers(initializer, spec)
return initializer
end
@@ -2257,7 +2269,7 @@ function lib:New(config)
local init, setting
if entryType == "header" then
- init = SB.Header(spec.name)
+ init = SB.Header(spec)
elseif entryType == "subheader" then
init = SB.Subheader(spec)
elseif entryType == "info" then
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index 1d776b9a..c81e8b37 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -1248,6 +1248,19 @@ describe("LibSettingsBuilder", function()
assert.is_not_nil(init2)
end)
+ it("Header accepts hidden predicates through spec tables", function()
+ local init = SB.Header({
+ name = "Quick Add",
+ hidden = function()
+ return true
+ end,
+ })
+
+ assert.are.equal("header", init._type)
+ assert.are.equal(1, #(init._shownPredicates or {}))
+ assert.is_false(init._shownPredicates[1]())
+ end)
+
-- Custom with varType override
it("Custom respects varType override", function()
local capturedVarType
@@ -1651,6 +1664,37 @@ describe("LibSettingsBuilder", function()
assert.is_true(headerCreated)
end)
+ it("RegisterFromTable passes hidden predicates to header initializers", function()
+ local capturedHeader
+ local origHeader = CreateSettingsListSectionHeaderInitializer
+ _G.CreateSettingsListSectionHeaderInitializer = function(text)
+ capturedHeader = origHeader(text)
+ return capturedHeader
+ end
+
+ local SB2 = createSB2("COND3", "CondTest3")
+
+ SB2.RegisterFromTable({
+ name = "Cond Section 3",
+ path = "global",
+ args = {
+ shown = {
+ type = "header",
+ name = "Conditional Header",
+ hidden = function()
+ return true
+ end,
+ order = 1,
+ },
+ },
+ })
+
+ _G.CreateSettingsListSectionHeaderInitializer = origHeader
+ assert.is_not_nil(capturedHeader)
+ assert.are.equal(1, #(capturedHeader._shownPredicates or {}))
+ assert.is_false(capturedHeader._shownPredicates[1]())
+ end)
+
it("RegisterFromTable rootCategory=true uses root instead of subcategory", function()
local SB2 = createSB2("ROOT1", "RootTest")
diff --git a/Locales/en.lua b/Locales/en.lua
index 18a3a281..76867767 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -231,20 +231,18 @@ L["ADD_RACIAL"] = "Add %s"
L["ADD_ITEM"] = "Item"
L["ADD_SPELL"] = "Spell"
L["EXTRA_ICONS_RESET_CONFIRM"] = "Reset extra icons to defaults?"
-L["ADD_NEW_HEADER"] = "Add New"
L["ENTRY_TYPE"] = "Type"
-L["ENTRY_VIEWER"] = "Viewer"
L["ENTRY_ID"] = "ID"
-L["UTILITY_VIEWER"] = "Utility"
-L["MAIN_VIEWER"] = "Main"
L["EXTRA_ICONS_ITEM_LOADING"] = "Loading item..."
L["ADD_ENTRY"] = "Add"
-L["PRESETS_HEADER"] = "Quick Add"
L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
+L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"] = "The most powerful item in this set will be displayed:"
L["REMOVE_ENTRY_CONFIRM"] = "Remove %s?"
L["MOVE_UP_TOOLTIP"] = "Move up"
L["MOVE_DOWN_TOOLTIP"] = "Move down"
L["MOVE_TO_VIEWER_TOOLTIP"] = "Move to %s viewer"
+L["ENABLE_TOOLTIP"] = "Enable"
+L["DISABLE_TOOLTIP"] = "Disable"
L["REMOVE_TOOLTIP"] = "Remove"
--------------------------------------------------------------------------------
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 569777ff..27de38b2 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -23,6 +23,10 @@ ns.Addon.ExtraIcons = ExtraIcons
---@field Cooldown Cooldown The cooldown overlay frame.
local BUILTIN_STACKS = ns.Constants.BUILTIN_STACKS
+local SUPPRESS_IN_RATED_PVP = {
+ combatPotions = true,
+ healthPotions = true,
+}
--- Viewer registry mapping viewer keys to their Blizzard frame globals.
local VIEWER_REGISTRY = {
@@ -55,6 +59,11 @@ end
-- Resolver Functions
--------------------------------------------------------------------------------
+local function isRatedPvPMap()
+ local pvp = C_PvP
+ return pvp and type(pvp.IsRatedMap) == "function" and pvp.IsRatedMap() or false
+end
+
--- Checks if an equipment slot has an on-use effect.
---@param slotId number Inventory slot ID.
---@return ECM_IconData|nil iconData Icon data if on-use, nil otherwise.
@@ -130,6 +139,9 @@ local function resolveEntry(entry)
if not stack then
return nil
end
+ if SUPPRESS_IN_RATED_PVP[entry.stackKey] and isRatedPvPMap() then
+ return nil
+ end
kind = stack.kind
slotId = stack.slotId
ids = stack.ids
@@ -157,7 +169,7 @@ local _resolvedItems = {}
local function resolveViewerEntries(entries)
wipe(_resolvedItems)
for _, entry in ipairs(entries) do
- local data = resolveEntry(entry)
+ local data = not entry.disabled and resolveEntry(entry) or nil
if data then
_resolvedItems[#_resolvedItems + 1] = data
end
@@ -393,18 +405,15 @@ end
---@param viewerKey string The viewer key ("utility" or "main").
---@param entries table[] The config entries for this viewer.
---@param isEditing boolean Whether edit mode is active.
----@param sharedOffsetX number|nil Pair-wide midpoint offset inherited from the main viewer.
---@return boolean changed Whether any icons were placed.
----@return number offsetX Local centering offset contributed by this viewer's own footprint.
-function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
+function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
local reg = VIEWER_REGISTRY[viewerKey]
local blizzFrame = _G[reg.blizzFrameKey]
local vs = self._viewers[viewerKey]
if not vs then
- return false, 0
+ return false
end
local container = vs.container
- sharedOffsetX = sharedOffsetX or 0
cacheOriginalPoint(vs, blizzFrame)
-- Resolve entries to displayable items
@@ -417,7 +426,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
if #items == 0 then
-- Restore viewer position and hide container
- applyViewerPoint(vs, blizzFrame, sharedOffsetX)
+ applyViewerPoint(vs, blizzFrame)
if isEditing then
vs.originalPoint = nil
end
@@ -425,7 +434,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
if viewerKey == "main" then
self:_updateViewerAnchor(viewerKey, blizzFrame, nil)
end
- return false, 0
+ return false
end
-- Hide all existing pool icons
@@ -480,7 +489,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
local viewerWidth = numActiveViewerIcons > 0 and blizzFrame:GetWidth() or 0
local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
- applyViewerPoint(vs, blizzFrame, viewerOffsetX + sharedOffsetX)
+ applyViewerPoint(vs, blizzFrame, viewerOffsetX)
-- Position and configure each icon
local borderScale = ns.Constants.EXTRA_ICON_BORDER_SCALE
@@ -517,7 +526,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
self:_updateViewerAnchor(viewerKey, blizzFrame, container)
end
- return true, viewerOffsetX
+ return true
end
--- Override UpdateLayout to position icons for all viewers.
@@ -541,18 +550,14 @@ function ExtraIcons:UpdateLayout(why)
-- the extra-icon containers.
local viewers = shouldShow and moduleConfig and moduleConfig.viewers
local anyPlaced = false
- local sharedOffsetX = 0
for i = 1, #VIEWER_ORDER do
local viewerKey = VIEWER_ORDER[i]
local entries = viewers and viewers[viewerKey] or {}
- local changed, localOffsetX = self:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
+ local changed = self:_updateSingleViewer(viewerKey, entries, isEditing)
if changed then
anyPlaced = true
end
- if viewerKey == "main" then
- sharedOffsetX = localOffsetX or 0
- end
end
-- Manage InnerFrame visibility (not handled by ApplyFramePosition since
@@ -637,11 +642,13 @@ function ExtraIcons:_rebuildTrackedSlots()
if config and config.viewers then
for _, entries in pairs(config.viewers) do
for _, entry in ipairs(entries) do
- local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
- local kind = stack and stack.kind or entry.kind
- local sid = stack and stack.slotId or entry.slotId
- if kind == "equipSlot" and sid then
- tracked[sid] = true
+ if not entry.disabled then
+ local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
+ local kind = stack and stack.kind or entry.kind
+ local sid = stack and stack.slotId or entry.slotId
+ if kind == "equipSlot" and sid then
+ tracked[sid] = true
+ end
end
end
end
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 8d31fbe9..a7b9ffad 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -200,6 +200,7 @@ describe("ExtraIcons real source", function()
local spellCooldowns
local spellCooldownInfos
local spellCharges
+ local ratedMap
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
@@ -215,6 +216,7 @@ describe("ExtraIcons real source", function()
"GetInventoryItemCooldown",
"C_Item",
"IsPlayerSpell",
+ "C_PvP",
})
end)
@@ -239,6 +241,7 @@ describe("ExtraIcons real source", function()
spellCooldowns = {}
spellCooldownInfos = {}
spellCharges = {}
+ ratedMap = false
ns = {
Log = function() end,
BarMixin = {
@@ -322,6 +325,11 @@ describe("ExtraIcons real source", function()
return spellCooldowns[spellId]
end,
}
+ _G.C_PvP = {
+ IsRatedMap = function()
+ return ratedMap
+ end,
+ }
_G.CreateFrame = function(frameType)
local frame = TestHelpers.makeFrame({ shown = true })
frame.SetFrameStrata = function() end
@@ -454,6 +462,20 @@ describe("ExtraIcons real source", function()
assert.is_nil(ExtraIcons._trackedEquipSlots[1])
end)
+ it("ignores disabled entries when rebuilding tracked equipment slots", function()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig(
+ { { stackKey = "trinket1", disabled = true } },
+ { { stackKey = "trinket2" } }
+ )
+ end
+
+ ExtraIcons:_rebuildTrackedSlots()
+
+ assert.is_nil(ExtraIcons._trackedEquipSlots[13])
+ assert.is_true(ExtraIcons._trackedEquipSlots[14])
+ end)
+
it("hooks edit mode only once", function()
ExtraIcons:HookEditMode()
ExtraIcons:HookEditMode()
@@ -771,6 +793,34 @@ describe("ExtraIcons real source", function()
assert.are.equal(87, x)
end)
+ it("skips disabled entries during layout resolution", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+ inventoryItemBySlot[14] = 102
+ inventoryTextureBySlot[14] = "trinket-2"
+ inventorySpellByItem[102] = 9002
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({
+ { stackKey = "trinket1", disabled = true },
+ { stackKey = "trinket2" },
+ })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.are.equal(14, ExtraIcons._viewers.utility.iconPool[1].slotId)
+ assert.are.equal(18, ExtraIcons._viewers.utility.container:GetWidth())
+ end)
+
it("publishes a combined main-viewer anchor when main extra icons are shown", function()
local activeFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
activeFrame.isActive = true
@@ -805,7 +855,7 @@ describe("ExtraIcons real source", function()
}, anchor.__anchors)
end)
- it("keeps utility aligned to the shared midpoint when an icon moves to main", function()
+ it("restores the utility viewer when an icon moves to main", function()
local utilityActiveFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
utilityActiveFrame.isActive = true
UtilityCooldownViewer.childXPadding = 4
@@ -850,7 +900,7 @@ describe("ExtraIcons real source", function()
local _, _, _, utilityAfterX = UtilityCooldownViewer:GetPoint(1)
local _, _, _, mainAfterX = EssentialCooldownViewer:GetPoint(1)
- assert.are.equal(-13, utilityAfterX)
+ assert.are.equal(0, utilityAfterX)
assert.are.equal(87, mainAfterX)
assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
assert.is_true(ExtraIcons._viewers.main.container:IsShown())
@@ -878,6 +928,36 @@ describe("ExtraIcons real source", function()
assert.are.equal(ns.Constants.DEMONIC_HEALTHSTONE_ITEM_ID, ExtraIcons._viewers.utility.iconPool[1].itemId)
end)
+ it("suppresses combat and health potions on rated maps while still showing healthstones", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ ratedMap = true
+ itemCounts[ns.Constants.COMBAT_POTIONS[1].itemID] = 1
+ itemIconsByID[ns.Constants.COMBAT_POTIONS[1].itemID] = "combat-potion"
+ itemCounts[ns.Constants.HEALTH_POTIONS[1].itemID] = 1
+ itemIconsByID[ns.Constants.HEALTH_POTIONS[1].itemID] = "health-potion"
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({
+ { stackKey = "combatPotions" },
+ { stackKey = "healthPotions" },
+ { stackKey = "healthstones" },
+ })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.are.equal(ns.Constants.HEALTHSTONE_ITEM_ID, ExtraIcons._viewers.utility.iconPool[1].itemId)
+ assert.are.equal(18, ExtraIcons._viewers.utility.container:GetWidth())
+ end)
+
it("anchors container to last active item frame when viewer layout is stale", function()
local staleFrame = TestHelpers.makeFrame({ shown = false, width = 22, height = 22 })
staleFrame.isActive = false
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 047eda49..4057a6fe 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -363,7 +363,10 @@ function TestHelpers.SetupSettingsStubs()
}
_G.CreateSettingsListSectionHeaderInitializer = function(text)
- return { _type = "header", _text = text }
+ local init = makeInitializer(nil)
+ init._type = "header"
+ init._text = text
+ return init
end
_G.CreateSettingsButtonInitializer = function(name, buttonText, onClick, tooltip)
@@ -825,6 +828,7 @@ TestHelpers.OPTIONS_GLOBALS = {
"SettingsListElementInitializer",
"GameFontHighlightSmall",
"GameFontNormal",
+ "GameFontDisable",
"SETTINGS_DEFAULTS",
"InCombatLockdown",
"UnitName",
@@ -845,9 +849,14 @@ TestHelpers.OPTIONS_GLOBALS = {
"C_Timer",
"C_PartyInfo",
"IsInInstance",
+ "GetInventoryItemID",
"GetInventoryItemTexture",
"C_Item",
"C_Spell",
+ "CreateTextureMarkup",
+ "CreateAtlasMarkup",
+ "GameTooltip",
+ "GameTooltip_Hide",
}
--- Load the live Constants.lua and Locales/en.lua to populate ECM.Constants and ECM.L.
@@ -882,6 +891,7 @@ function TestHelpers.SetupOptionsGlobals()
_G.ECM_DeepEquals = deepEquals
_G.GameFontHighlightSmall = "GameFontHighlightSmall"
_G.GameFontNormal = "GameFontNormal"
+ _G.GameFontDisable = "GameFontDisable"
_G.SETTINGS_DEFAULTS = "Defaults"
_G.InCombatLockdown = function()
return false
@@ -894,6 +904,9 @@ function TestHelpers.SetupOptionsGlobals()
_G.IsInInstance = function()
return false
end
+ _G.GetInventoryItemID = function()
+ return nil
+ end
_G.GetInventoryItemTexture = function()
return nil
end
@@ -917,6 +930,45 @@ function TestHelpers.SetupOptionsGlobals()
return nil
end,
}
+ _G.CreateTextureMarkup = function(texture)
+ return "|T" .. tostring(texture) .. "|t"
+ end
+ _G.CreateAtlasMarkup = function(atlas)
+ return "|A" .. tostring(atlas) .. "|a"
+ end
+ _G.GameTooltip = {
+ _title = nil,
+ _lines = {},
+ _owner = nil,
+ _anchor = nil,
+ _shown = false,
+ SetOwner = function(self, owner, anchor)
+ self._owner = owner
+ self._anchor = anchor
+ end,
+ ClearLines = function(self)
+ self._title = nil
+ self._lines = {}
+ end,
+ SetText = function(self, text)
+ self._title = text
+ self._lines = {}
+ end,
+ AddLine = function(self, text)
+ self._lines[#self._lines + 1] = text
+ end,
+ Show = function(self)
+ self._shown = true
+ end,
+ Hide = function(self)
+ self._shown = false
+ end,
+ }
+ _G.GameTooltip_Hide = function()
+ if _G.GameTooltip and _G.GameTooltip.Hide then
+ _G.GameTooltip:Hide()
+ end
+ end
_G.UnitName = function()
return "TestPlayer"
end
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index a6a9fc10..e88f617d 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -77,24 +77,42 @@ describe("ExtraIconsOptions data helpers", function()
end)
describe("_getEntryName", function()
- local savedCSpell
+ local savedCSpell, savedCItem, savedInventoryItemID
before_each(function()
savedCSpell = _G.C_Spell
+ savedCItem = _G.C_Item
+ savedInventoryItemID = _G.GetInventoryItemID
_G.C_Spell = {
GetSpellName = function(spellId)
if spellId == 59752 then return "Every Man for Himself" end
return nil
end,
}
+ _G.C_Item = {
+ GetItemNameByID = function(itemId)
+ if itemId == 99999 then return "Test Item" end
+ if itemId == 10001 then return "Gladiator's Badge" end
+ return nil
+ end,
+ DoesItemExistByID = function(itemId)
+ return itemId == 99999 or itemId == 10001
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+ _G.GetInventoryItemID = function(_, slotId)
+ return slotId == 13 and 10001 or nil
+ end
end)
after_each(function()
_G.C_Spell = savedCSpell
+ _G.C_Item = savedCItem
+ _G.GetInventoryItemID = savedInventoryItemID
end)
it("returns builtin stack label", function()
- assert.are.equal("Trinket 1", ExtraIconsOptions._getEntryName({ stackKey = "trinket1" }))
+ assert.are.equal("Trinket 1 [Gladiator's Badge]", ExtraIconsOptions._getEntryName({ stackKey = "trinket1" }))
assert.are.equal("Combat Potions", ExtraIconsOptions._getEntryName({ stackKey = "combatPotions" }))
end)
@@ -108,8 +126,8 @@ describe("ExtraIconsOptions data helpers", function()
ExtraIconsOptions._getEntryName({ kind = "spell", ids = { 12345 } }))
end)
- it("returns generic item label", function()
- assert.are.equal("Item 99999",
+ it("returns item name from API for item entries", function()
+ assert.are.equal("Test Item",
ExtraIconsOptions._getEntryName({ kind = "item", ids = { { itemID = 99999 } } }))
end)
@@ -161,6 +179,59 @@ describe("ExtraIconsOptions data helpers", function()
end)
end)
+ describe("_setEntryDisabled", function()
+ it("sets and clears the disabled flag", function()
+ local profile = { extraIcons = { viewers = { utility = { { stackKey = "trinket1" } } } } }
+
+ ExtraIconsOptions._setEntryDisabled(profile, "utility", 1, true)
+ assert.is_true(profile.extraIcons.viewers.utility[1].disabled)
+
+ ExtraIconsOptions._setEntryDisabled(profile, "utility", 1, false)
+ assert.is_nil(profile.extraIcons.viewers.utility[1].disabled)
+ end)
+ end)
+
+ describe("_toggleBuiltinRow", function()
+ it("toggles the disabled flag for persisted builtin rows", function()
+ local profile = { extraIcons = { viewers = { utility = { { stackKey = "trinket1" } } } } }
+
+ ExtraIconsOptions._toggleBuiltinRow(profile, "utility", 1, "trinket1")
+ assert.is_true(profile.extraIcons.viewers.utility[1].disabled)
+
+ ExtraIconsOptions._toggleBuiltinRow(profile, "utility", 1, "trinket1")
+ assert.is_nil(profile.extraIcons.viewers.utility[1].disabled)
+ end)
+
+ it("adds missing builtin rows when toggled from a placeholder", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+
+ ExtraIconsOptions._toggleBuiltinRow(profile, "utility", nil, "trinket1")
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal("trinket1", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.is_nil(profile.extraIcons.viewers.utility[1].disabled)
+ end)
+ end)
+
+ describe("_toggleCurrentRacialRow", function()
+ it("adds the current racial when toggled from a placeholder", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, "utility", nil, 59752)
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.same({ 59752 }, profile.extraIcons.viewers.utility[1].ids)
+ end)
+
+ it("removes a persisted racial row when toggled", function()
+ local profile = { extraIcons = { viewers = { utility = { { kind = "spell", ids = { 59752 } } }, main = {} } } }
+
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, "utility", 1, 59752)
+
+ assert.are.equal(0, #profile.extraIcons.viewers.utility)
+ end)
+ end)
+
describe("_removeEntry", function()
it("removes at given index", function()
local profile = { extraIcons = { viewers = { utility = {
@@ -292,6 +363,9 @@ describe("ExtraIconsOptions data helpers", function()
GetSpellName = function(spellId)
return spellId == 12345 and "Test Spell" or nil
end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
}
_G.C_Item = {
DoesItemExistByID = function(itemId)
@@ -300,6 +374,9 @@ describe("ExtraIconsOptions data helpers", function()
GetItemNameByID = function(itemId)
return itemId == 777 and "Test Item" or nil
end,
+ GetItemIconByID = function(itemId)
+ return itemId == 12345 and "item-tex" or nil
+ end,
RequestLoadItemDataByID = function() end,
}
end)
@@ -317,8 +394,8 @@ describe("ExtraIconsOptions data helpers", function()
assert.is_nil(ExtraIconsOptions._resolveDraftEntryName("spell", "99999"))
end)
- it("returns loading text for valid uncached items", function()
- assert.are.equal(ns.L["EXTRA_ICONS_ITEM_LOADING"], ExtraIconsOptions._resolveDraftEntryName("item", "12345"))
+ it("returns nil while valid items are still pending", function()
+ assert.is_nil(ExtraIconsOptions._resolveDraftEntryName("item", "12345"))
end)
it("returns nil for invalid items", function()
@@ -326,6 +403,74 @@ describe("ExtraIconsOptions data helpers", function()
end)
end)
+ describe("_resolveDraftEntryPreview", function()
+ local savedCSpell, savedCItem
+
+ before_each(function()
+ savedCSpell = _G.C_Spell
+ savedCItem = _G.C_Item
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
+ }
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 777
+ end,
+ GetItemNameByID = function(itemId)
+ return itemId == 777 and "Test Item" or nil
+ end,
+ GetItemIconByID = function(itemId)
+ return itemId == 777 and "item-tex" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+ end)
+
+ after_each(function()
+ _G.C_Spell = savedCSpell
+ _G.C_Item = savedCItem
+ end)
+
+ it("returns spell preview text and icon", function()
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview("spell", "12345")
+ assert.are.equal("resolved", status)
+ assert.are.equal("Test Spell", name)
+ assert.are.equal("spell-tex", icon)
+ end)
+
+ it("returns item preview text and icon", function()
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview("item", "777")
+ assert.are.equal("resolved", status)
+ assert.are.equal("Test Item", name)
+ assert.are.equal("item-tex", icon)
+ end)
+
+ it("returns pending for items that exist but are not loaded yet", function()
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 555
+ end,
+ GetItemNameByID = function()
+ return nil
+ end,
+ GetItemIconByID = function(itemId)
+ return itemId == 555 and "pending-item-tex" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview("item", "555")
+ assert.are.equal("pending", status)
+ assert.is_nil(name)
+ assert.are.equal("pending-item-tex", icon)
+ end)
+ end)
+
describe("_otherViewer", function()
it("utility returns main", function()
assert.are.equal("main", ExtraIconsOptions._otherViewer("utility"))
@@ -446,6 +591,70 @@ describe("ExtraIconsOptions data helpers", function()
assert.is_true(ExtraIconsOptions._isRacialForCurrentPlayer({ kind = "spell", ids = { 33697 } }))
end)
end)
+
+ describe("_isCurrentRacialEntry", function()
+ local savedUnitRace
+
+ before_each(function()
+ savedUnitRace = _G.UnitRace
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ end)
+
+ after_each(function()
+ _G.UnitRace = savedUnitRace
+ end)
+
+ it("returns true for the current player's racial", function()
+ assert.is_true(ExtraIconsOptions._isCurrentRacialEntry({ kind = "spell", ids = { 59752 } }))
+ end)
+
+ it("returns false for non-racial entries", function()
+ assert.is_false(ExtraIconsOptions._isCurrentRacialEntry({ stackKey = "trinket1" }))
+ end)
+ end)
+
+ describe("_buildViewerRows", function()
+ local savedUnitRace
+
+ before_each(function()
+ savedUnitRace = _G.UnitRace
+ _G.UnitRace = function() return "Human", "Human", 1 end
+ end)
+
+ after_each(function()
+ _G.UnitRace = savedUnitRace
+ end)
+
+ it("adds builtin and current-racial placeholders to utility when absent", function()
+ local viewers = {
+ utility = { { stackKey = "trinket1" } },
+ main = {},
+ }
+
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
+
+ assert.are.equal("entry", rows[1].rowType)
+ assert.are.equal("builtinPlaceholder", rows[2].rowType)
+ assert.are.equal("trinket2", rows[2].stackKey)
+ assert.are.equal("racialPlaceholder", rows[#rows].rowType)
+ assert.are.equal(59752, rows[#rows].spellId)
+ end)
+
+ it("hides foreign-race racials from built rows", function()
+ local viewers = {
+ utility = {
+ { kind = "spell", ids = { 33697 } },
+ { stackKey = "trinket1" },
+ },
+ main = {},
+ }
+
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
+
+ assert.are.equal("entry", rows[1].rowType)
+ assert.are.equal("trinket1", rows[1].displayEntry.stackKey)
+ end)
+ end)
end)
--------------------------------------------------------------------------------
@@ -480,21 +689,59 @@ describe("ExtraIconsOptions settings page", function()
ns.OptionsSections.ExtraIcons.RegisterSettings(SB)
end)
+ local function refresh()
+ ns.ExtraIconsOptions._refresh()
+ end
+
+ local function getVisibleRows(viewerKey)
+ local rows = {}
+ for _, row in ipairs(ns.ExtraIconsOptions._viewerCanvas._viewerRowPools[viewerKey]) do
+ if row:IsShown() then
+ rows[#rows + 1] = row
+ end
+ end
+ return rows
+ end
+
+ local function findVisibleRowByText(viewerKey, text)
+ for _, row in ipairs(getVisibleRows(viewerKey)) do
+ if row._label:GetText() == text then
+ return row
+ end
+ end
+ return nil
+ end
+
+ local function getDraftRow(viewerKey)
+ refresh()
+ return ns.ExtraIconsOptions._viewerCanvas._viewerDraftRows[viewerKey]
+ end
+
+ local function setDraftText(viewerKey, text)
+ local row = getDraftRow(viewerKey)
+ row._editBox:SetText(text)
+ row._editBox:GetScript("OnTextChanged")(row._editBox)
+ return row
+ end
+
describe("settings registration", function()
it("creates a subcategory", function()
assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
end)
- it("only exposes the viewer canvas for testing", function()
+ it("exposes the viewer canvas and inline draft state for testing", function()
local opts = ns.ExtraIconsOptions
assert.is_not_nil(opts._viewerCanvas)
+ assert.is_nil(opts._draftEntryCanvas)
+ assert.is_table(opts._draftStates)
assert.is_nil(opts._addFormCanvas)
assert.is_nil(opts._presetsCanvas)
end)
- it("viewer canvas exposes row pools and headers", function()
+ it("viewer canvas exposes row pools, draft rows, and headers", function()
local vc = ns.ExtraIconsOptions._viewerCanvas
assert.is_table(vc._viewerRowPools)
+ assert.is_table(vc._viewerDraftRows)
assert.is_table(vc._viewerHeaders)
assert.is_table(vc._viewerEmptyLabels)
assert.is_not_nil(vc._viewerHeaders.utility._title)
@@ -503,122 +750,175 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], vc._viewerHeaders.main._title:GetText())
end)
- it("registers the native add form above the viewer canvas", function()
+ it("registers only the enabled toggle and viewer canvas", function()
assert.is_not_nil(capturedTable)
- assert.are.equal("header", capturedTable.args.addHeader.type)
- assert.are.equal("select", capturedTable.args.addType.type)
- assert.are.equal("select", capturedTable.args.addViewer.type)
- assert.are.equal("input", capturedTable.args.addId.type)
- assert.are.equal("button", capturedTable.args.addEntry.type)
- assert.is_not_nil(capturedTable.args.quickAdd_trinket1)
- assert.are.equal("button", capturedTable.args.quickAdd_trinket1.type)
- assert.are.equal("button", capturedTable.args.quickAddRacial.type)
+ assert.are.equal("toggle", capturedTable.args.enabled.type)
assert.are.equal("canvas", capturedTable.args.viewers.type)
- assert.is_true(capturedTable.args.addHeader.order < capturedTable.args.viewers.order)
- assert.is_true(capturedTable.args.addType.order < capturedTable.args.viewers.order)
- assert.is_true(capturedTable.args.addViewer.order < capturedTable.args.viewers.order)
- assert.is_true(capturedTable.args.addId.order < capturedTable.args.viewers.order)
- assert.is_true(capturedTable.args.addEntry.order < capturedTable.args.viewers.order)
- assert.is_true(capturedTable.args.viewers.order < capturedTable.args.presetsHeader.order)
- assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAdd_trinket1.order)
- assert.is_true(capturedTable.args.viewers.order < capturedTable.args.quickAddRacial.order)
+ assert.is_nil(capturedTable.args.addHeader)
+ assert.is_nil(capturedTable.args.quickAdd_trinket1)
+ assert.is_nil(capturedTable.args.quickAddRacial)
end)
- it("wires the add form to ephemeral draft state and single-ID preview resolution", function()
- local opts = ns.ExtraIconsOptions
-
- assert.are.equal("spell", capturedTable.args.addType.get())
- assert.are.equal("utility", capturedTable.args.addViewer.get())
- assert.are.equal("", capturedTable.args.addId.get())
- assert.is_true(capturedTable.args.addEntry.disabled())
-
+ it("uses inline draft rows to add custom entries per viewer", function()
_G.C_Spell = {
GetSpellName = function(spellId)
return spellId == 12345 and "Test Spell" or nil
end,
- GetSpellTexture = function()
- return nil
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
end,
}
- capturedTable.args.addId.set("12345")
+ setDraftText("main", "12345")
- assert.are.equal("12345", opts._formState.idText)
- assert.are.equal("Test Spell", capturedTable.args.addId.resolveText("12345"))
- assert.is_false(capturedTable.args.addEntry.disabled())
- end)
+ local draftRow = getDraftRow("main")
+ assert.are.equal("Test Spell", draftRow._previewLabel:GetText())
+ assert.is_true(draftRow._previewLabel:IsShown())
+ assert.is_true(draftRow._addBtn:IsShown())
- it("adds a custom spell entry and clears the draft ID", function()
- local opts = ns.ExtraIconsOptions
+ draftRow._addBtn:GetScript("OnClick")()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
+ assert.are.equal("", ns.ExtraIconsOptions._draftStates.main.idText)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
+ assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
+ end)
+
+ it("shows pending draft resolution with ellipsis and no add button", function()
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 777
end,
- GetSpellTexture = function()
+ GetItemNameByID = function()
return nil
end,
+ GetItemIconByID = function(itemId)
+ return itemId == 777 and "item-tex" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
}
- capturedTable.args.addType.set("spell")
- capturedTable.args.addViewer.set("main")
- capturedTable.args.addId.set("12345")
- capturedTable.args.addEntry.onClick()
+ local draftRow = getDraftRow("utility")
+ draftRow._typeBtn:GetScript("OnClick")()
+ setDraftText("utility", "777")
- assert.are.equal("", opts._formState.idText)
- assert.are.equal(1, #profile.extraIcons.viewers.main)
- assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
- assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
+ draftRow = getDraftRow("utility")
+ assert.are.equal("...", draftRow._previewLabel:GetText())
+ assert.is_true(draftRow._previewLabel:IsShown())
+ assert.is_false(draftRow._addBtn:IsShown())
end)
- it("hides the quick-add heading when no quick-add entries are visible", function()
- local viewers = profile.extraIcons.viewers
- local racial = ns.Constants.RACIAL_ABILITIES.Human
- viewers.utility = {}
+ it("disables builtin rows instead of removing them", function()
+ refresh()
- for _, stackKey in ipairs(ns.Constants.BUILTIN_STACK_ORDER) do
- viewers.utility[#viewers.utility + 1] = { stackKey = stackKey }
- end
- viewers.utility[#viewers.utility + 1] = { kind = "spell", ids = { racial.spellId } }
+ local row = findVisibleRowByText("utility", "Trinket 1")
+ assert.is_not_nil(row)
+ assert.are.equal("x", row._deleteBtn:GetText())
+
+ row._deleteBtn:GetScript("OnClick")()
+
+ assert.is_true(profile.extraIcons.viewers.utility[1].disabled)
+ row = findVisibleRowByText("utility", "Trinket 1")
+ assert.is_not_nil(row)
+ assert.are.equal("+", row._deleteBtn:GetText())
+ assert.are.equal("GameFontDisable", row._label:GetFontObject())
+ end)
- assert.is_true(capturedTable.args.presetsHeader.hidden())
+ it("renders missing builtin rows as disabled placeholders in utility", function()
+ profile.extraIcons.viewers.utility = {}
+ profile.extraIcons.viewers.main = {}
- ns.ExtraIconsOptions._removeEntry(profile, "utility", 1)
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Trinket 1")
+ assert.is_not_nil(row)
+ assert.are.equal("+", row._deleteBtn:GetText())
- assert.is_false(capturedTable.args.presetsHeader.hidden())
- assert.is_false(capturedTable.args.quickAdd_trinket1.hidden())
+ row._deleteBtn:GetScript("OnClick")()
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal("trinket1", profile.extraIcons.viewers.utility[1].stackKey)
+ assert.is_nil(profile.extraIcons.viewers.utility[1].disabled)
end)
- it("exposes a refresh function", function()
- assert.is_function(ns.ExtraIconsOptions._refresh)
+ it("shows the current racial as a placeholder when absent and restores it after removal", function()
+ local getShownPopupName = TestHelpers.InstallPopupAutoAccept()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ if spellId == 59752 then return "Every Man for Himself" end
+ return nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 59752 and "racial-tex" or nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = {}
+ profile.extraIcons.viewers.main = {}
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Every Man for Himself")
+ assert.is_not_nil(row)
+ assert.are.equal("+", row._deleteBtn:GetText())
+
+ row._deleteBtn:GetScript("OnClick")()
+
+ assert.is_true(ns.ExtraIconsOptions._isRacialPresent(profile.extraIcons.viewers, 59752))
+ row = findVisibleRowByText("utility", "Every Man for Himself")
+ assert.is_not_nil(row)
+ assert.are.equal("x", row._deleteBtn:GetText())
+
+ row._deleteBtn:GetScript("OnClick")()
+
+ assert.are.equal("ECM_CONFIRM_REMOVE_EXTRA_ICON", getShownPopupName())
+ assert.is_false(ns.ExtraIconsOptions._isRacialPresent(profile.extraIcons.viewers, 59752))
+ row = findVisibleRowByText("utility", "Every Man for Himself")
+ assert.is_not_nil(row)
+ assert.are.equal("+", row._deleteBtn:GetText())
end)
- it("redisplays the active category so removed quick-add entries can reappear", function()
- local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
- local redisplayedCategory = nil
+ it("does not display racials from other races", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ if spellId == 33697 then return "Blood Fury" end
+ if spellId == 59752 then return "Every Man for Himself" end
+ return nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 33697 } } }
+
+ refresh()
- rawset(SettingsPanel, "IsShown", function()
- return true
- end)
- rawset(SettingsPanel, "GetCurrentCategory", function()
- return category
- end)
- rawset(SettingsPanel, "DisplayCategory", function(_, cat)
- redisplayedCategory = cat
- end)
+ assert.is_nil(findVisibleRowByText("utility", "Blood Fury"))
+ assert.is_not_nil(findVisibleRowByText("utility", "Every Man for Himself"))
+ end)
- profile.extraIcons.viewers.utility = { { stackKey = "trinket1" } }
- assert.is_true(capturedTable.args.quickAdd_trinket1.hidden())
+ it("moves entries between viewers", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
+ profile.extraIcons.viewers.main = {}
- ns.ExtraIconsOptions._removeEntry(profile, "utility", 1)
- ns.ExtraIconsOptions._refresh()
+ refresh()
- assert.is_false(capturedTable.args.quickAdd_trinket1.hidden())
- assert.are.equal(category, redisplayedCategory)
+ local row = findVisibleRowByText("utility", "Test Spell")
+ row._moveBtn:GetScript("OnClick")()
+
+ assert.are.equal(0, #profile.extraIcons.viewers.utility)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
end)
it("rebinds whole-row mouseover handlers on refresh", function()
- ns.ExtraIconsOptions._refresh()
+ refresh()
local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
assert.is_not_nil(row)
@@ -635,21 +935,20 @@ describe("ExtraIconsOptions settings page", function()
assert.is_false(row._highlight:IsShown())
end)
- it("resets and rebinds pooled row mouseover on subsequent refreshes", function()
- ns.ExtraIconsOptions._refresh()
+ it("aligns viewer rows with the settings label column", function()
+ refresh()
local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
- assert.is_not_nil(row)
-
- row:GetScript("OnEnter")(row)
- assert.is_true(row._highlight:IsShown())
-
- ns.ExtraIconsOptions._refresh()
+ local point, _, _, x = row:GetPoint(1)
+ assert.are.equal("TOPLEFT", point)
+ assert.are.equal(37, x)
+ end)
- assert.is_true(row:IsMouseEnabled())
- assert.is_function(row:GetScript("OnEnter"))
- assert.is_function(row:GetScript("OnLeave"))
- assert.is_false(row._highlight:IsShown())
+ it("creates inline draft rows at the same alignment", function()
+ local row = getDraftRow("utility")
+ local point, _, _, x = row:GetPoint(1)
+ assert.are.equal("TOPLEFT", point)
+ assert.are.equal(37, x)
end)
end)
end)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index be1419db..b8a768ee 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -13,7 +13,16 @@ local RACIAL_ABILITIES = C.RACIAL_ABILITIES
local ROW_HEIGHT = 26
local ICON_SIZE = 20
local BTN_SIZE = 22
-local CONTENT_MARGIN = 10
+local DRAFT_ENTRY_ROW_HEIGHT = 28
+local DRAFT_ENTRY_PREVIEW_ICON_SIZE = 16
+local DRAFT_TYPE_BUTTON_WIDTH = 58
+local DRAFT_ID_BOX_WIDTH = 120
+local DRAFT_ADD_BUTTON_WIDTH = 44
+local TOOLTIP_ITEM_ICON_SIZE = 14
+local TOOLTIP_QUALITY_ICON_SIZE = 14
+local SETTINGS_LABEL_X = 37
+local DEFAULT_SPECIAL_VIEWER = "utility"
+local DRAFT_PENDING_TEXT = "..."
local VIEWER_ORDER = { "utility", "main" }
local VIEWER_LABELS = {
utility = "UTILITY_VIEWER_ICONS",
@@ -39,28 +48,159 @@ function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
return false
end
+local function getCurrentRacialEntry()
+ local raceName, raceFile = UnitRace("player")
+ return (raceFile and RACIAL_ABILITIES[raceFile]) or (raceName and RACIAL_ABILITIES[raceName])
+end
+
+local function getCurrentRacialSpellId()
+ local racial = getCurrentRacialEntry()
+ return racial and racial.spellId or nil
+end
+
+local function getEntrySpellId(entry)
+ if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
+ return nil
+ end
+
+ local first = entry.ids[1]
+ return type(first) == "table" and first.spellId or first
+end
+
--- Check if a racial spellId is present in any viewer's entries.
function ExtraIconsOptions._isRacialPresent(viewers, spellId)
for _, entries in pairs(viewers) do
for _, entry in ipairs(entries) do
- if entry.kind == "spell" and entry.ids then
- for _, id in ipairs(entry.ids) do
- local sid = type(id) == "table" and id.spellId or id
- if sid == spellId then
- return true
- end
- end
+ if getEntrySpellId(entry) == spellId then
+ return true
end
end
end
return false
end
+function ExtraIconsOptions._isCurrentRacialEntry(entry)
+ local racialSpellId = getCurrentRacialSpellId()
+ return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
+end
+
+local function getItemIdFromEntry(entry)
+ return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
+end
+
+local function getItemDisplayName(itemId)
+ if not itemId then
+ return nil
+ end
+
+ local name = C_Item.GetItemNameByID(itemId)
+ if name then
+ return name
+ end
+
+ if C_Item.DoesItemExistByID(itemId) then
+ C_Item.RequestLoadItemDataByID(itemId)
+ return L["EXTRA_ICONS_ITEM_LOADING"]
+ end
+
+ return "Item " .. tostring(itemId)
+end
+
+local function getEquippedItemDisplayName(slotId)
+ local itemId = GetInventoryItemID("player", slotId)
+ if not itemId then
+ return nil
+ end
+
+ return getItemDisplayName(itemId)
+end
+
+local function getTextureMarkup(texture, size)
+ if not texture or type(CreateTextureMarkup) ~= "function" then
+ return nil
+ end
+
+ return CreateTextureMarkup(texture, 64, 64, size, size, 0, 1, 0, 1)
+end
+
+local function getQualityMarkup(quality)
+ if not quality then
+ return nil
+ end
+
+ if type(CreateAtlasMarkup) == "function" then
+ return CreateAtlasMarkup(
+ "Professions-Icon-Quality-Tier" .. tostring(quality) .. "-Small",
+ TOOLTIP_QUALITY_ICON_SIZE,
+ TOOLTIP_QUALITY_ICON_SIZE
+ )
+ end
+
+ return "[R" .. tostring(quality) .. "]"
+end
+
+local function buildTooltipLine(...)
+ local parts = {}
+ for i = 1, select("#", ...) do
+ local value = select(i, ...)
+ if value and value ~= "" then
+ parts[#parts + 1] = value
+ end
+ end
+
+ return table.concat(parts, " ")
+end
+
+local function setItemStackTooltip(owner, entry)
+ local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
+ if not stack or stack.kind ~= "item" or not stack.ids or #stack.ids == 0 then
+ return false
+ end
+
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(stack.label, 1, 1, 1)
+ GameTooltip:AddLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], nil, nil, nil, true)
+
+ for _, itemEntry in ipairs(stack.ids) do
+ local itemId = getItemIdFromEntry(itemEntry)
+ local icon = itemId and C_Item.GetItemIconByID(itemId) or nil
+ local itemName = getItemDisplayName(itemId)
+ local quality = type(itemEntry) == "table" and itemEntry.quality or nil
+ GameTooltip:AddLine(
+ buildTooltipLine(
+ getTextureMarkup(icon, TOOLTIP_ITEM_ICON_SIZE),
+ itemName or ("Item " .. tostring(itemId)),
+ getQualityMarkup(quality)
+ ),
+ 1,
+ 1,
+ 1
+ )
+ end
+
+ GameTooltip:Show()
+ return true
+end
+
--- Get display name for a config entry.
function ExtraIconsOptions._getEntryName(entry)
if entry.stackKey then
local stack = BUILTIN_STACKS[entry.stackKey]
- return stack and stack.label or entry.stackKey
+ if not stack then
+ return entry.stackKey
+ end
+
+ if stack.kind == "equipSlot" then
+ local itemName = getEquippedItemDisplayName(stack.slotId)
+ if itemName then
+ return ("%s [%s]"):format(stack.label, itemName)
+ end
+ end
+
+ return stack.label
end
if entry.kind == "spell" and entry.ids then
local first = entry.ids[1]
@@ -70,7 +210,7 @@ function ExtraIconsOptions._getEntryName(entry)
end
if entry.kind == "item" and entry.ids then
local first = entry.ids[1]
- return "Item " .. tostring(type(first) == "table" and first.itemID or first)
+ return getItemDisplayName(getItemIdFromEntry(first))
end
return "Unknown"
end
@@ -140,6 +280,42 @@ function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
end
end
+function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ local entry = entries and entries[index]
+ if not entry then
+ return
+ end
+
+ entry.disabled = disabled and true or nil
+end
+
+function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
+ if index then
+ local entries = profile.extraIcons.viewers[viewerKey]
+ local entry = entries and entries[index]
+ if not entry then
+ return
+ end
+
+ ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled)
+ return
+ end
+
+ ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+end
+
+function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
+ if index then
+ ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ return
+ end
+
+ if spellId then
+ ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ end
+end
+
--- Swap entry with its neighbor (-1 = up, +1 = down).
function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
local entries = profile.extraIcons.viewers[viewerKey]
@@ -191,36 +367,110 @@ function ExtraIconsOptions._parseSingleId(text)
return num
end
---- Resolve a draft spell or item ID to a display name.
+--- Resolve a draft spell or item ID to preview data.
---@param kind string
---@param text string|nil
----@return string|nil
-function ExtraIconsOptions._resolveDraftEntryName(kind, text)
+---@return string status "invalid"|"pending"|"resolved"
+---@return string|nil name
+---@return string|number|nil icon
+function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
local id = ExtraIconsOptions._parseSingleId(text)
if not id then
- return nil
+ return "invalid", nil, nil
end
if kind == "spell" then
- return C_Spell.GetSpellName(id)
+ local name = C_Spell.GetSpellName(id)
+ if not name then
+ return "invalid", nil, nil
+ end
+
+ return "resolved", name, C_Spell.GetSpellTexture(id)
end
if kind == "item" then
if not C_Item.DoesItemExistByID(id) then
- return nil
+ return "invalid", nil, nil
end
local name = C_Item.GetItemNameByID(id)
+ local icon = C_Item.GetItemIconByID(id)
if name then
- return name
+ return "resolved", name, icon
end
C_Item.RequestLoadItemDataByID(id)
- return L["EXTRA_ICONS_ITEM_LOADING"]
+ return "pending", nil, icon
end
- return nil
+ return "invalid", nil, nil
+end
+
+--- Resolve a draft spell or item ID to a display name.
+---@param kind string
+---@param text string|nil
+---@return string|nil
+function ExtraIconsOptions._resolveDraftEntryName(kind, text)
+ local status, name = ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
+ if status ~= "resolved" then
+ return nil
+ end
+ return name
+end
+
+function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
+ local rows = {}
+ local entries = viewers[viewerKey] or {}
+
+ for index, entry in ipairs(entries) do
+ if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
+ rows[#rows + 1] = {
+ rowType = "entry",
+ viewerKey = viewerKey,
+ index = index,
+ entry = entry,
+ displayEntry = entry,
+ isBuiltin = entry.stackKey ~= nil,
+ isCurrentRacial = ExtraIconsOptions._isCurrentRacialEntry(entry),
+ isPlaceholder = false,
+ isDisabled = entry.disabled == true,
+ }
+ end
+ end
+
+ if viewerKey == DEFAULT_SPECIAL_VIEWER then
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ rows[#rows + 1] = {
+ rowType = "builtinPlaceholder",
+ viewerKey = viewerKey,
+ stackKey = stackKey,
+ displayEntry = { stackKey = stackKey },
+ isBuiltin = true,
+ isCurrentRacial = false,
+ isPlaceholder = true,
+ isDisabled = true,
+ }
+ end
+ end
+
+ local racialSpellId = getCurrentRacialSpellId()
+ if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
+ rows[#rows + 1] = {
+ rowType = "racialPlaceholder",
+ viewerKey = viewerKey,
+ spellId = racialSpellId,
+ displayEntry = { kind = "spell", ids = { racialSpellId } },
+ isBuiltin = false,
+ isCurrentRacial = true,
+ isPlaceholder = true,
+ isDisabled = true,
+ }
+ end
+ end
+
+ return rows
end
--- Get the opposite viewer key.
@@ -253,7 +503,7 @@ local function clearRowMouseover(row)
end
end
-local function setRowMouseover(row)
+local function setRowMouseover(row, tooltipBuilder)
if row._highlight then
row._highlight:Hide()
end
@@ -264,27 +514,28 @@ local function setRowMouseover(row)
if self._highlight then
self._highlight:Show()
end
+ if tooltipBuilder then
+ tooltipBuilder(self)
+ end
end)
row:SetScript("OnLeave", function(self)
if self._highlight then
self._highlight:Hide()
end
+ GameTooltip_Hide()
end)
end
--- Check if a racial entry belongs to the current player character.
function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- if not (entry.kind == "spell" and entry.ids) then return true end
- local _, raceFile = UnitRace("player")
- local racial = raceFile and RACIAL_ABILITIES[raceFile]
+ local spellId = getEntrySpellId(entry)
+ if not spellId then return true end
+ local racial = getCurrentRacialEntry()
if not racial then return true end
for _, racialEntry in pairs(RACIAL_ABILITIES) do
if racialEntry ~= racial then
- for _, id in ipairs(entry.ids) do
- local sid = type(id) == "table" and id.spellId or id
- if sid == racialEntry.spellId then
- return false
- end
+ if spellId == racialEntry.spellId then
+ return false
end
end
end
@@ -337,6 +588,50 @@ local function createEntryRow(parent)
return row
end
+local function createDraftRow(parent)
+ local row = CreateFrame("Frame", nil, parent)
+ row:SetHeight(DRAFT_ENTRY_ROW_HEIGHT)
+
+ row._typeBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._typeBtn:SetSize(DRAFT_TYPE_BUTTON_WIDTH, BTN_SIZE)
+ row._typeBtn:SetPoint("LEFT", row, "LEFT", 0, 0)
+
+ row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
+ row._editBox:SetPoint("LEFT", row._typeBtn, "RIGHT", 6, 0)
+ row._editBox:SetSize(DRAFT_ID_BOX_WIDTH, 20)
+ row._editBox:SetAutoFocus(false)
+ if type(row._editBox.SetNumeric) == "function" then
+ row._editBox:SetNumeric(true)
+ end
+ if type(row._editBox.SetMaxLetters) == "function" then
+ row._editBox:SetMaxLetters(10)
+ end
+ if type(row._editBox.SetTextInsets) == "function" then
+ row._editBox:SetTextInsets(6, 6, 0, 0)
+ end
+
+ row._previewIcon = row:CreateTexture(nil, "ARTWORK")
+ row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
+ row._previewIcon:SetSize(DRAFT_ENTRY_PREVIEW_ICON_SIZE, DRAFT_ENTRY_PREVIEW_ICON_SIZE)
+ row._previewIcon:Hide()
+
+ row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0)
+ row._previewLabel:SetJustifyH("LEFT")
+ row._previewLabel:SetWordWrap(false)
+ row._previewLabel:Hide()
+
+ row._addBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._addBtn:SetSize(DRAFT_ADD_BUTTON_WIDTH, BTN_SIZE)
+ row._addBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
+ row._addBtn:SetText(L["ADD_ENTRY"])
+ row._addBtn:Hide()
+
+ row._previewLabel:SetPoint("RIGHT", row._addBtn, "LEFT", -6, 0)
+
+ return row
+end
+
local function createViewerHeaderRow(parent, SB, text, headerHeight)
local row = CreateFrame("Frame", nil, parent)
row:SetHeight(headerHeight)
@@ -353,6 +648,7 @@ local function createViewerListCanvas(SB, headerHeight)
frame:SetHeight(400)
frame._viewerRowPools = { utility = {}, main = {} }
+ frame._viewerDraftRows = {}
frame._viewerHeaders = {}
frame._viewerEmptyLabels = {}
@@ -378,8 +674,9 @@ function ExtraIconsOptions.RegisterSettings(SB)
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
local viewerHeaderHeight = (SB.SetCanvasLayoutDefaults and SB.SetCanvasLayoutDefaults().headerHeight) or 50
local viewerCanvas = createViewerListCanvas(SB, viewerHeaderHeight)
- local pageCategory = nil
+
ExtraIconsOptions._viewerCanvas = viewerCanvas
+ ExtraIconsOptions._draftEntryCanvas = nil
ExtraIconsOptions._addFormCanvas = nil
ExtraIconsOptions._presetsCanvas = nil
@@ -391,87 +688,294 @@ function ExtraIconsOptions.RegisterSettings(SB)
return getProfile().extraIcons.viewers
end
- ExtraIconsOptions._formState = ExtraIconsOptions._formState or {
- kind = "spell",
- viewer = "utility",
- idText = "",
- }
- local formState = ExtraIconsOptions._formState
+ ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
+ local draftStates = ExtraIconsOptions._draftStates
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey] = draftStates[viewerKey] or {
+ kind = "spell",
+ idText = "",
+ }
+ end
local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
- local function getResolvedDraftName()
- return ExtraIconsOptions._resolveDraftEntryName(formState.kind, formState.idText)
- end
+ local function refreshVisibleSettingsControls()
+ local panel = SettingsPanel
+ if not panel or not panel.IsShown or not panel:IsShown() then
+ return
+ end
- local function canAddDraftEntry()
- local id = ExtraIconsOptions._parseSingleId(formState.idText)
- if not id then
- return false
+ local settingsList = panel.GetSettingsList and panel:GetSettingsList()
+ local scrollBox = settingsList and settingsList.ScrollBox
+ if scrollBox and scrollBox.ForEachFrame then
+ scrollBox:ForEachFrame(function(frame)
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ end)
end
+ end
- if formState.kind == "spell" then
- return getResolvedDraftName() ~= nil
+ local function resetDraftStates()
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey].kind = "spell"
+ draftStates[viewerKey].idText = ""
end
+ end
- return true
+ local function getDraftResolution(viewerKey)
+ local draftState = draftStates[viewerKey]
+ return ExtraIconsOptions._resolveDraftEntryPreview(draftState.kind, draftState.idText)
end
- local function addDraftEntry()
- local id = ExtraIconsOptions._parseSingleId(formState.idText)
- if not id or not canAddDraftEntry() then
+ local function canAddDraftEntry(viewerKey)
+ local status = getDraftResolution(viewerKey)
+ return status == "resolved"
+ end
+
+ local function addDraftEntry(viewerKey)
+ local draftState = draftStates[viewerKey]
+ local id = ExtraIconsOptions._parseSingleId(draftState.idText)
+ if not id or not canAddDraftEntry(viewerKey) then
return
end
- ExtraIconsOptions._addCustomEntry(getProfile(), formState.viewer, formState.kind, { id })
- formState.idText = ""
+ ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, draftState.kind, { id })
+ draftState.idText = ""
scheduleUpdate()
ExtraIconsOptions._refresh()
end
- local function getPlayerRacialSpellId()
- local _, raceFile = UnitRace("player")
- local racial = raceFile and RACIAL_ABILITIES[raceFile]
- return racial and racial.spellId or nil
+ local function restoreDefaultExtraIcons()
+ local defaultsProfile = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ local defaultsConfig = defaultsProfile and defaultsProfile.extraIcons
+ if not defaultsConfig then
+ return
+ end
+
+ ns.Addon.db.profile.extraIcons = ns.CloneValue(defaultsConfig)
+ resetDraftStates()
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
end
- local function hasVisibleQuickAddEntries()
- local viewers = getViewers()
- for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- return true
+ local function setRowVisualState(row, isDisabledRow)
+ local alpha = isDisabledRow and 0.55 or 1
+ if row._label and type(row._label.SetFontObject) == "function" then
+ local fontObject = isDisabledRow and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal
+ if fontObject then
+ row._label:SetFontObject(fontObject)
end
end
+ if row._label and type(row._label.SetAlpha) == "function" then
+ row._label:SetAlpha(alpha)
+ end
+ if row._icon and type(row._icon.SetAlpha) == "function" then
+ row._icon:SetAlpha(alpha)
+ end
+ if row._icon and type(row._icon.SetDesaturated) == "function" then
+ row._icon:SetDesaturated(isDisabledRow)
+ end
+ if row._icon and type(row._icon.SetVertexColor) == "function" then
+ if isDisabledRow then
+ row._icon:SetVertexColor(0.6, 0.6, 0.6, 1)
+ else
+ row._icon:SetVertexColor(1, 1, 1, 1)
+ end
+ end
+ if row._label and type(row._label.SetTextColor) == "function" then
+ if isDisabledRow then
+ row._label:SetTextColor(0.65, 0.65, 0.65, 1)
+ else
+ row._label:SetTextColor(1, 0.82, 0, 1)
+ end
+ end
+ end
+
+ local function ensureDraftRow(viewerKey)
+ local row = viewerCanvas._viewerDraftRows[viewerKey]
+ if row then
+ return row
+ end
+
+ row = createDraftRow(viewerCanvas)
+ viewerCanvas._viewerDraftRows[viewerKey] = row
+ row._viewerKey = viewerKey
- local racialSpellId = getPlayerRacialSpellId()
- return racialSpellId ~= nil and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId)
+ setButtonTooltip(row._typeBtn, L["ENTRY_TYPE"])
+
+ row._typeBtn:SetScript("OnClick", function()
+ if isDisabled() then
+ return
+ end
+
+ local draftState = draftStates[viewerKey]
+ draftState.kind = draftState.kind == "spell" and "item" or "spell"
+ ExtraIconsOptions._refresh()
+ end)
+
+ row._editBox:SetScript("OnTextChanged", function(self)
+ if row._syncingText then
+ return
+ end
+
+ draftStates[viewerKey].idText = self:GetText() or ""
+ ExtraIconsOptions._refresh()
+ end)
+
+ row._editBox:SetScript("OnEnterPressed", function()
+ addDraftEntry(viewerKey)
+ end)
+
+ row._addBtn:SetScript("OnClick", function()
+ addDraftEntry(viewerKey)
+ end)
+
+ return row
end
- local function refreshVisibleSettingsControls()
- local panel = SettingsPanel
- if not panel or not panel.IsShown or not panel:IsShown() then
- return
+ local function refreshDraftRow(viewerKey, row)
+ local draftState = draftStates[viewerKey]
+ local controlsDisabled = isDisabled()
+ local status, name, icon = getDraftResolution(viewerKey)
+
+ row._typeBtn:SetText(draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"])
+ if row._typeBtn.SetEnabled then
+ row._typeBtn:SetEnabled(not controlsDisabled)
end
- local currentCategory = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil
- if pageCategory and currentCategory == pageCategory and panel.DisplayCategory then
- panel:DisplayCategory(currentCategory)
- return
+ if row._editBox.GetText and row._editBox:GetText() ~= draftState.idText then
+ row._syncingText = true
+ row._editBox:SetText(draftState.idText)
+ row._syncingText = nil
+ end
+ if row._editBox.SetEnabled then
+ row._editBox:SetEnabled(not controlsDisabled)
end
- local settingsList = panel.GetSettingsList and panel:GetSettingsList()
- local scrollBox = settingsList and settingsList.ScrollBox
- if scrollBox and scrollBox.ForEachFrame then
- scrollBox:ForEachFrame(function(frame)
- if frame.EvaluateState then
- frame:EvaluateState()
- end
+ if icon then
+ row._previewIcon:SetTexture(icon)
+ row._previewIcon:Show()
+ else
+ row._previewIcon:SetTexture(nil)
+ row._previewIcon:Hide()
+ end
+
+ if status == "resolved" then
+ row._previewLabel:SetText(name or "")
+ row._previewLabel:Show()
+ row._addBtn:Show()
+ elseif status == "pending" then
+ row._previewLabel:SetText(DRAFT_PENDING_TEXT)
+ row._previewLabel:Show()
+ row._addBtn:Hide()
+ else
+ row._previewLabel:SetText("")
+ row._previewLabel:Hide()
+ row._addBtn:Hide()
+ end
+
+ if row._addBtn.SetEnabled then
+ row._addBtn:SetEnabled(not controlsDisabled and status == "resolved")
+ end
+ end
+
+ local function configureEntryRow(row, rowData)
+ local controlsDisabled = isDisabled()
+ local displayEntry = rowData.displayEntry
+ local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
+ local entries = getViewers()[rowData.viewerKey] or {}
+
+ row._label:SetText(ExtraIconsOptions._getEntryName(displayEntry))
+ row._icon:SetTexture(ExtraIconsOptions._getEntryIcon(displayEntry) or 134400)
+ setRowVisualState(row, rowData.isDisabled)
+
+ row._upBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil and rowData.index > 1)
+ row._downBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil and rowData.index < #entries)
+ row._moveBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil)
+ row._deleteBtn:SetEnabled(not controlsDisabled)
+ row._moveBtn:SetText(rowData.viewerKey == "utility" and ">" or "<")
+
+ setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
+ setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
+ setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(otherViewer))
+
+ row._upBtn:SetScript("OnClick", function()
+ if rowData.index == nil then
+ return
+ end
+
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end)
+
+ row._downBtn:SetScript("OnClick", function()
+ if rowData.index == nil then
+ return
+ end
+
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end)
+
+ row._moveBtn:SetScript("OnClick", function()
+ if rowData.index == nil then
+ return
+ end
+
+ ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end)
+
+ if rowData.isBuiltin then
+ row._deleteBtn:SetText(rowData.isDisabled and "+" or "x")
+ setButtonTooltip(row._deleteBtn, rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["DISABLE_TOOLTIP"])
+ row._deleteBtn:SetScript("OnClick", function()
+ ExtraIconsOptions._toggleBuiltinRow(
+ getProfile(),
+ rowData.viewerKey,
+ rowData.index,
+ rowData.stackKey or displayEntry.stackKey
+ )
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end)
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ row._deleteBtn:SetText("+")
+ setButtonTooltip(row._deleteBtn, L["ADD_ENTRY"])
+ row._deleteBtn:SetScript("OnClick", function()
+ ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end)
+ else
+ row._deleteBtn:SetText("x")
+ setButtonTooltip(row._deleteBtn, L["REMOVE_TOOLTIP"])
+ row._deleteBtn:SetScript("OnClick", function()
+ local entryName = ExtraIconsOptions._getEntryName(displayEntry)
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
+ onAccept = function()
+ ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
+ scheduleUpdate()
+ ExtraIconsOptions._refresh()
+ end,
+ })
end)
end
+
+ clearRowMouseover(row)
+ setRowMouseover(row, function(self)
+ setItemStackTooltip(self, displayEntry)
+ end)
end
+ viewerCanvas.OnDefault = restoreDefaultExtraIcons
+
--------------------------------------------------------------------
-- Refresh: viewer lists canvas
--------------------------------------------------------------------
@@ -488,86 +992,46 @@ function ExtraIconsOptions.RegisterSettings(SB)
y = y - viewerHeaderHeight
local pool = viewerCanvas._viewerRowPools[viewerKey]
- local entries = viewers[viewerKey] or {}
-
- local visibleEntries = {}
- for i, entry in ipairs(entries) do
- if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
- visibleEntries[#visibleEntries + 1] = { index = i, entry = entry }
- end
- end
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
for _, row in ipairs(pool) do
clearRowMouseover(row)
row:Hide()
end
- if #visibleEntries == 0 then
+ if #rows == 0 then
viewerCanvas._viewerEmptyLabels[viewerKey]:ClearAllPoints()
- viewerCanvas._viewerEmptyLabels[viewerKey]:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN + 8, y)
+ viewerCanvas._viewerEmptyLabels[viewerKey]:SetPoint(
+ "TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
viewerCanvas._viewerEmptyLabels[viewerKey]:Show()
y = y - ROW_HEIGHT
else
viewerCanvas._viewerEmptyLabels[viewerKey]:Hide()
end
- for vi, vis in ipairs(visibleEntries) do
- local entry = vis.entry
- local ci = vis.index
- local row = pool[vi]
+ for rowIndex, rowData in ipairs(rows) do
+ local row = pool[rowIndex]
if not row then
row = createEntryRow(viewerCanvas)
- pool[vi] = row
+ pool[rowIndex] = row
end
- row._label:SetText(ExtraIconsOptions._getEntryName(entry))
- row._icon:SetTexture(ExtraIconsOptions._getEntryIcon(entry) or 134400)
- row._upBtn:SetEnabled(ci > 1)
- row._downBtn:SetEnabled(ci < #entries)
-
- local other = ExtraIconsOptions._otherViewer(viewerKey)
-
- setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
- setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
- setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(other))
- setButtonTooltip(row._deleteBtn, L["REMOVE_TOOLTIP"])
-
- row._upBtn:SetScript("OnClick", function()
- ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, -1)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
- row._downBtn:SetScript("OnClick", function()
- ExtraIconsOptions._reorderEntry(getProfile(), viewerKey, ci, 1)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
- row._moveBtn:SetText(viewerKey == "utility" and ">" or "<")
- row._moveBtn:SetScript("OnClick", function()
- ExtraIconsOptions._moveEntry(getProfile(), viewerKey, other, ci)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
- row._deleteBtn:SetScript("OnClick", function()
- local entryName = ExtraIconsOptions._getEntryName(entry)
- StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
- onAccept = function()
- ExtraIconsOptions._removeEntry(getProfile(), viewerKey, ci)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end,
- })
- end)
-
- setRowMouseover(row)
-
+ configureEntryRow(row, rowData)
row:ClearAllPoints()
- row:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", CONTENT_MARGIN, y)
+ row:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
row:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
row:Show()
y = y - ROW_HEIGHT
end
+ local draftRow = ensureDraftRow(viewerKey)
+ refreshDraftRow(viewerKey, draftRow)
+ draftRow:ClearAllPoints()
+ draftRow:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
+ draftRow:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
+ draftRow:Show()
+ y = y - DRAFT_ENTRY_ROW_HEIGHT
+
y = y - 12
end
end
@@ -580,186 +1044,35 @@ function ExtraIconsOptions.RegisterSettings(SB)
refreshVisibleSettingsControls()
end
- local function addStackPreset(stackKey)
- local viewers = getViewers()
- if ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- return
- end
-
- ExtraIconsOptions._addStackKey(getProfile(), "utility", stackKey)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end
-
- local function addRacialPreset()
- local spellId = getPlayerRacialSpellId()
- local viewers = getViewers()
- if not spellId or ExtraIconsOptions._isRacialPresent(viewers, spellId) then
- return
- end
-
- ExtraIconsOptions._addRacial(getProfile(), "utility", spellId)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end
-
- --------------------------------------------------------------------
- -- Register via table
- --------------------------------------------------------------------
- local addTypeValues = {
- spell = L["ADD_SPELL"],
- item = L["ADD_ITEM"],
- }
- local addViewerValues = {
- utility = L["UTILITY_VIEWER"],
- main = L["MAIN_VIEWER"],
- }
-
- local args = {
- enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"],
- desc = L["ENABLE_EXTRA_ICONS_DESC"],
- order = 0,
- onSet = function(value)
- local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
- handler(value)
- end,
- },
- addHeader = {
- type = "header",
- name = L["ADD_NEW_HEADER"],
- order = 10,
- },
- addType = {
- type = "select",
- name = L["ENTRY_TYPE"],
- values = addTypeValues,
- key = "addType",
- default = formState.kind,
- layout = false,
- order = 11,
- disabled = isDisabled,
- get = function()
- return formState.kind
- end,
- set = function(value)
- formState.kind = value
- end,
- },
- addViewer = {
- type = "select",
- name = L["ENTRY_VIEWER"],
- values = addViewerValues,
- key = "addViewer",
- default = formState.viewer,
- layout = false,
- order = 12,
- disabled = isDisabled,
- get = function()
- return formState.viewer
- end,
- set = function(value)
- formState.viewer = value
- end,
- },
- addId = {
- type = "input",
- name = L["ENTRY_ID"],
- key = "addId",
- default = formState.idText,
- debounce = 1,
- disabled = isDisabled,
- layout = false,
- maxLetters = 10,
- numeric = true,
- order = 13,
- watch = { "addType" },
- get = function()
- return formState.idText
- end,
- set = function(value)
- formState.idText = value or ""
- end,
- resolveText = function(text)
- return ExtraIconsOptions._resolveDraftEntryName(formState.kind, text)
- end,
- },
- addEntry = {
- type = "button",
- name = L["ADD_ENTRY"],
- buttonText = L["ADD_ENTRY"],
- disabled = function()
- return isDisabled() or not canAddDraftEntry()
- end,
- layout = false,
- onClick = addDraftEntry,
- order = 14,
- },
- }
-
- for i, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- local stack = BUILTIN_STACKS[stackKey]
- args["quickAdd_" .. stackKey] = {
- type = "button",
- name = stack.label,
- buttonText = L["ADD_ENTRY"],
- hidden = function()
- return ExtraIconsOptions._isStackKeyPresent(getViewers(), stackKey)
- end,
- disabled = isDisabled,
- order = 41 + i,
- onClick = function()
- addStackPreset(stackKey)
- end,
- }
- end
-
- local racialSpellId = getPlayerRacialSpellId()
- local racialName = racialSpellId and C_Spell.GetSpellName(racialSpellId) or nil
- args.quickAddRacial = {
- type = "button",
- name = racialName or "Racial",
- buttonText = L["ADD_ENTRY"],
- hidden = function()
- local spellId = getPlayerRacialSpellId()
- return spellId == nil or ExtraIconsOptions._isRacialPresent(getViewers(), spellId)
- end,
- disabled = isDisabled,
- order = 42 + #BUILTIN_STACK_ORDER,
- onClick = addRacialPreset,
- }
-
- args.viewers = {
- type = "canvas",
- canvas = viewerCanvas,
- height = 400,
- disabled = isDisabled,
- order = 30,
- }
-
- args.presetsHeader = {
- type = "header",
- name = L["PRESETS_HEADER"],
- order = 40,
- disabled = isDisabled,
- hidden = function()
- return not hasVisibleQuickAddEntries()
- end,
- }
-
SB.RegisterFromTable({
name = L["EXTRA_ICONS"],
path = "extraIcons",
onShow = function()
ExtraIconsOptions._refresh()
end,
- args = args,
+ args = {
+ enabled = {
+ type = "toggle",
+ path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"],
+ desc = L["ENABLE_EXTRA_ICONS_DESC"],
+ order = 0,
+ onSet = function(value)
+ local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
+ handler(value)
+ end,
+ },
+ viewers = {
+ type = "canvas",
+ canvas = viewerCanvas,
+ height = 400,
+ disabled = isDisabled,
+ order = 10,
+ },
+ },
})
- pageCategory = SB.GetSubcategory(L["EXTRA_ICONS"])
- ExtraIconsOptions._category = pageCategory
+ ExtraIconsOptions._category = SB.GetSubcategory(L["EXTRA_ICONS"])
end
ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
From a9ba26c35aafd5a78492ff4da9ab4e113c3012ce Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 9 Apr 2026 15:00:15 +1000
Subject: [PATCH 06/53] Improve spell color cleanup and extra icon settings UX
- add reconcile and remove-stale flows for incomplete spell color keys
- remove matching stale spell color entries from persisted and discovered stores
- show full spell color key details on ctrl-hover
- block duplicate extra icon adds and moves across viewers
- refresh pending item drafts automatically and improve special-row ordering/tooltips
- stop spell/item toggle clicks from refocusing the extra icon input
- update dialogs, tests, docs, locale strings, and luacheck config
---
.luacheckrc | 16 +-
ARCHITECTURE.md | 5 +-
Locales/en.lua | 26 ++
SpellColors.lua | 98 ++++++
Tests/SpellColors_spec.lua | 35 +++
Tests/TestHelpers.lua | 96 +++++-
Tests/UI/BuffBarsOptions_spec.lua | 209 +++++++++++++
Tests/UI/ExtraIconsOptions_spec.lua | 375 +++++++++++++++++++++-
UI/BuffBarsOptions.lua | 210 ++++++++++++-
UI/Dialogs.lua | 40 +++
UI/ExtraIconsOptions.lua | 467 +++++++++++++++++++++++++---
UI/ResourceBarOptions.lua | 2 +-
12 files changed, 1517 insertions(+), 62 deletions(-)
diff --git a/.luacheckrc b/.luacheckrc
index 8e00e16a..8bab74db 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -18,6 +18,18 @@ ignore = {
"212/..." -- unused variable length argument
}
+files = {
+ ["**/Tests/**"] = {
+ std = "+busted",
+ read_globals = {
+ assert = { other_fields = true },
+ mock = { other_fields = true },
+ spy = { other_fields = true },
+ stub = { other_fields = true }
+ }
+ }
+}
+
globals = {
"LSB_DEBUG",
"LibLSMSettingsWidgets_FontPickerMixin",
@@ -40,13 +52,15 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip',
-- Externals
"AddonCompartmentFrame",
- "C_AddOns", "C_CVar", "C_EditMode", "C_Item", "C_PartyInfo", "C_Spell", "C_SpellBook", "C_Timer", "C_UnitAuras",
+ "C_AddOns", "C_CVar", "C_EditMode", "C_Item", "C_PartyInfo", "C_PvP", "C_Spell", "C_SpellBook", "C_Timer", "C_UnitAuras",
"CANCEL",
+ "CreateAtlasMarkup",
"ColorPickerFrame",
"CLOSE",
"CreateColorFromHexString",
"CreateDataProvider",
"CreateFrame",
+ "CreateTextureMarkup",
"CreateScrollBoxListLinearView",
"CreateSettingsButtonInitializer",
"CreateSettingsListSectionHeaderInitializer",
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index e1a5e64c..a821671b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -382,7 +382,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Built-in rows use the trailing button as an enable/disable toggle instead of removal, and missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section. The current-player racial is also synthesized as a disabled placeholder when absent; adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the canvas as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section. The current-player racial is also synthesized as a disabled placeholder when absent; adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables. Special-row behavior is explained through a short legend plus row-specific tooltips.
### FrameUtil (`ns.FrameUtil`)
@@ -421,11 +421,14 @@ Multi-tier key system for per-spell color customization on buff bars. Keys match
| `GetDefaultColor()` | Return default color for class/spec |
| `SetDefaultColor(color)` | Set default color for class/spec |
| `ReconcileAllKeys(keys)` | Batch-reconcile keys (propagate most-recent across tiers) |
+| `RemoveEntriesByKeys(keys)` | Remove matching persisted and discovered spell-color keys |
| `DiscoverBar(frame)` | Register a discovered bar key |
| `ClearDiscoveredKeys()` | Clear discovered key cache |
| `ClearCurrentSpecColors()` | Clear all colors for current class/spec |
| `SetConfigAccessor(accessor)` | Inject config accessor (decouples from AceDB) |
+The spell-color settings canvas (`UI/BuffBarsOptions.lua`) merges persisted and discovered keys into one list, enables `Reconcile` and `Remove Stale` only when a row is still missing one or more identifiers, and lets `Remove Stale` confirmed-delete incomplete entries from both the current-spec stores and the runtime discovered-key cache while echoing each removal to chat.
+
### ClassUtil (`ns.ClassUtil`)
| Method | Description |
diff --git a/Locales/en.lua b/Locales/en.lua
index 76867767..82979e54 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -198,10 +198,21 @@ L["SPELL_COLORS_DESC"] =
L["SPELL_COLORS_SECRET_NAMES_DESC"] =
"One or more spell names have become secret. This can be cleared by reloading the UI outside of restricted area, typically dungeons, raids, delves, and PVP."
L["SPELL_COLORS_RELOAD_BUTTON"] = "Reload UI"
+L["SPELL_COLORS_RECONCILE_BUTTON"] = "Reconcile"
+L["SPELL_COLORS_REMOVE_STALE_BUTTON"] = "Remove Stale"
+L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"] =
+ "Remove partial or stale entries that were discovered while spell information was secret. Reconcile should be attempted before this."
+L["SPELL_COLORS_DONT_REMOVE"] = "Don't Remove"
+L["SPELL_COLORS_REMOVED_STALE_ENTRY"] = "Removed stale spell color entry: %s"
L["SPELL_COLORS_RESET_CONFIRM"] = "Are you sure you want to reset all spell colors for this spec?"
L["SPELL_COLORS_COMBAT_WARNING"] = "|cffFF0000These settings cannot be changed while in combat lockdown.|r"
L["SPELL_COLORS_SECRETS_WARNING"] =
"|cffFFDD3CSpell names are currently secret. Changes are blocked until you reload your UI out of combat.|r"
+L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"] = "Spell color keys"
+L["SPELL_COLORS_KEY_SPELL_NAME"] = "Spell name: %s"
+L["SPELL_COLORS_KEY_SPELL_ID"] = "Spell ID: %s"
+L["SPELL_COLORS_KEY_COOLDOWN_ID"] = "Cooldown ID: %s"
+L["SPELL_COLORS_KEY_TEXTURE_FILE_ID"] = "Texture File ID: %s"
L["DEFAULT_COLOR"] = "Default color"
--------------------------------------------------------------------------------
@@ -227,20 +238,35 @@ L["ENABLE_EXTRA_ICONS_DESC"] =
"Display icons for equipped on-use trinkets, select consumables, and custom spells or items to the right of cooldown viewers."
L["UTILITY_VIEWER_ICONS"] = "Utility Viewer Icons"
L["MAIN_VIEWER_ICONS"] = "Main Viewer Icons"
+L["UTILITY_VIEWER_SHORT"] = "Utility"
+L["MAIN_VIEWER_SHORT"] = "Main"
L["ADD_RACIAL"] = "Add %s"
L["ADD_ITEM"] = "Item"
L["ADD_SPELL"] = "Spell"
L["EXTRA_ICONS_RESET_CONFIRM"] = "Reset extra icons to defaults?"
L["ENTRY_TYPE"] = "Type"
L["ENTRY_ID"] = "ID"
+L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] = "Spell ID"
+L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"] = "Item ID"
L["EXTRA_ICONS_ITEM_LOADING"] = "Loading item..."
L["ADD_ENTRY"] = "Add"
L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
+L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"] =
+ "Special rows: built-ins use x to hide and + to enable. Custom rows use x to remove. Disabled rows stay listed but are hidden in the real viewers."
L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"] = "The most powerful item in this set will be displayed:"
+L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"] = "Toggle between adding a spell ID or an item ID."
+L["EXTRA_ICONS_DUPLICATE_ENTRY"] = "Already in %s"
+L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"] = "Already in %s viewer"
+L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"] =
+ "Built-in row placeholder. Enable it to show this icon in the viewer."
+L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"] = "Disabled built-ins stay in their default order."
+L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"] =
+ "This is your current racial placeholder. Enable it to start tracking it."
L["REMOVE_ENTRY_CONFIRM"] = "Remove %s?"
L["MOVE_UP_TOOLTIP"] = "Move up"
L["MOVE_DOWN_TOOLTIP"] = "Move down"
L["MOVE_TO_VIEWER_TOOLTIP"] = "Move to %s viewer"
+L["EXTRA_ICONS_HIDE_TOOLTIP"] = "Hide"
L["ENABLE_TOOLTIP"] = "Enable"
L["DISABLE_TOOLTIP"] = "Disable"
L["REMOVE_TOOLTIP"] = "Remove"
diff --git a/SpellColors.lua b/SpellColors.lua
index 1018e985..84b0b9b1 100644
--- a/SpellColors.lua
+++ b/SpellColors.lua
@@ -704,6 +704,80 @@ local function repairCurrentSpecStoreMetadata()
return changed
end
+---@param storeTable table|nil
+---@param tierKeyType "spellName"|"spellID"|"cooldownID"|"textureFileID"
+---@param target ECM_SpellColorKey|nil
+---@return boolean removed
+local function removeMatchingStoreEntries(storeTable, tierKeyType, target)
+ if type(storeTable) ~= "table" or not target then
+ return false
+ end
+
+ local keysToRemove = nil
+ for rawKey, entry in pairs(storeTable) do
+ local candidate = buildKeyFromEntry(entry, tierKeyType, rawKey)
+ if candidate and keysMatch(candidate, target) then
+ keysToRemove = keysToRemove or {}
+ keysToRemove[#keysToRemove + 1] = rawKey
+ end
+ end
+
+ if not keysToRemove then
+ return false
+ end
+
+ for _, rawKey in ipairs(keysToRemove) do
+ storeTable[rawKey] = nil
+ end
+
+ return true
+end
+
+---@param target ECM_SpellColorKey|nil
+---@return boolean removed
+local function removeMatchingPersistedEntries(target)
+ local tables = scopeTables()
+ if not tables or not target then
+ return false
+ end
+
+ local removed = false
+ for _, scopeKey in ipairs(KEY_DEFS) do
+ if removeMatchingStoreEntries(tables[scopeKey], KEY_TYPE_TO_STORE[scopeKey], target) then
+ removed = true
+ end
+ end
+
+ return removed
+end
+
+---@param target ECM_SpellColorKey|nil
+---@return boolean removed
+local function removeMatchingDiscoveredEntries(target)
+ if not target then
+ return false
+ end
+
+ local removed = false
+ local nextIndex = 1
+
+ for index = 1, #_discoveredKeys do
+ local key = _discoveredKeys[index]
+ if key and keysMatch(key, target) then
+ removed = true
+ else
+ _discoveredKeys[nextIndex] = key
+ nextIndex = nextIndex + 1
+ end
+ end
+
+ for index = nextIndex, #_discoveredKeys do
+ _discoveredKeys[index] = nil
+ end
+
+ return removed
+end
+
---------------------------------------------------------------------------
-- Public store API
---------------------------------------------------------------------------
@@ -913,6 +987,30 @@ function SpellColors.ReconcileAllKeys(keys)
return changed + repairCurrentSpecStoreMetadata()
end
+--- Removes persisted and discovered entries matching the given keys.
+---@param keys (ECM_SpellColorKey|table)[]
+---@return ECM_SpellColorKey[] removedKeys
+function SpellColors.RemoveEntriesByKeys(keys)
+ local removedKeys = {}
+
+ if type(keys) ~= "table" then
+ return removedKeys
+ end
+
+ for _, key in ipairs(keys) do
+ local normalized = normalizeKey(key)
+ if normalized then
+ local removedPersisted = removeMatchingPersistedEntries(normalized)
+ local removedDiscovered = removeMatchingDiscoveredEntries(normalized)
+ if removedPersisted or removedDiscovered then
+ removedKeys[#removedKeys + 1] = normalized
+ end
+ end
+ end
+
+ return removedKeys
+end
+
--- Registers a bar frame's identifying values in the runtime discovered cache.
--- Called during layout so values are captured before they become secret.
---@param frame ECM_BuffBarMixin
diff --git a/Tests/SpellColors_spec.lua b/Tests/SpellColors_spec.lua
index e09ea993..01ec4503 100644
--- a/Tests/SpellColors_spec.lua
+++ b/Tests/SpellColors_spec.lua
@@ -452,6 +452,41 @@ describe("SpellColors", function()
assert.are.same(newer, SpellColors.GetColorByKey({ spellName = "Fel Rush" }))
end)
+ it("RemoveEntriesByKeys clears matching persisted and discovered entries", function()
+ SpellColors.ClearDiscoveredKeys()
+
+ local staleColor = color(0.4, 0.5, 0.6)
+ local keepColor = color(0.8, 0.2, 0.1)
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), staleColor)
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Keep Me", 12345, 67890, 13579), keepColor)
+
+ SpellColors.DiscoverBar(makeFrame({
+ spellName = "Immolation Aura",
+ spellID = 258920,
+ cooldownID = 77,
+ textureFileID = 9001,
+ }))
+ SpellColors.DiscoverBar(makeFrame({
+ spellName = "Keep Me",
+ spellID = 12345,
+ cooldownID = 67890,
+ textureFileID = 13579,
+ }))
+
+ local removed = SpellColors.RemoveEntriesByKeys({
+ SpellColors.MakeKey("Immolation Aura", 258920, nil, nil),
+ })
+
+ assert.are.equal(1, #removed)
+ assert.is_nil(SpellColors.GetColorByKey({ spellName = "Immolation Aura" }))
+ assert.is_nil(SpellColors.GetColorByKey({ spellID = 258920 }))
+
+ local entries = SpellColors.GetAllColorEntries()
+ assert.are.equal(1, #entries)
+ assert.are.equal("Keep Me", entries[1].key.spellName)
+ assert.are.same(keepColor, entries[1].color)
+ end)
+
it("GetAllColorEntries returns deduplicated entries for shared references", function()
local c = color(0.2, 0.3, 0.4)
SpellColors.SetColorByKey(SpellColors.MakeKey("Eye Beam", 198013, 55, 1111), c)
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 4057a6fe..4a52ac91 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -851,6 +851,7 @@ TestHelpers.OPTIONS_GLOBALS = {
"IsInInstance",
"GetInventoryItemID",
"GetInventoryItemTexture",
+ "IsPlayerSpell",
"C_Item",
"C_Spell",
"CreateTextureMarkup",
@@ -910,6 +911,9 @@ function TestHelpers.SetupOptionsGlobals()
_G.GetInventoryItemTexture = function()
return nil
end
+ _G.IsPlayerSpell = function()
+ return false
+ end
_G.C_Item = {
GetItemIconByID = function()
return nil
@@ -1031,15 +1035,25 @@ function TestHelpers.SetupOptionsGlobals()
end
-- Minimal CreateFrame stub for canvas layouts
+ local focusedFrame = nil
local function makeFrameStub()
local f = { scripts = {}, hooks = {}, callbacks = {}, _children = {} }
local noop = function() end
local value, minValue, maxValue, stepValue = nil, 0, 0, 1
local shown = false
local mouseEnabled = false
+ local enabled = true
local text = ""
local fontObject = nil
+ local highlighted = false
+ local hasFocus = false
local anchors = {}
+ local registeredEvents = {}
+ local alpha = 1
+ local desaturated = false
+ local vertexColor = { 1, 1, 1, 1 }
+ local textColor = { 1, 1, 1, 1 }
+ local texture = nil
f.SetScript = function(self, event, fn)
self.scripts[event] = fn
end
@@ -1081,9 +1095,14 @@ function TestHelpers.SetupOptionsGlobals()
f.IsShown = function()
return shown
end
- f.SetAlpha = noop
- f.EnableMouse = function(_, enabled)
- mouseEnabled = not not enabled
+ f.SetAlpha = function(_, newAlpha)
+ alpha = newAlpha
+ end
+ f.GetAlpha = function()
+ return alpha
+ end
+ f.EnableMouse = function(_, isEnabled)
+ mouseEnabled = not not isEnabled
end
f.IsMouseEnabled = function()
return mouseEnabled
@@ -1091,12 +1110,27 @@ function TestHelpers.SetupOptionsGlobals()
f.GetChildren = function()
return
end
- f.SetEnabled = noop
+ f.SetEnabled = function(_, isEnabled)
+ enabled = not not isEnabled
+ end
+ f.IsEnabled = function()
+ return enabled
+ end
+ f.RegisterEvent = function(_, event)
+ registeredEvents[event] = true
+ end
+ f.UnregisterEvent = function(_, event)
+ registeredEvents[event] = nil
+ end
+ f.IsEventRegistered = function(_, event)
+ return registeredEvents[event] == true
+ end
f.RegisterForClicks = noop
f.SetAutoFocus = noop
f.SetNumeric = noop
f.SetText = function(_, newText)
text = newText
+ highlighted = false
end
f.GetText = function()
return text
@@ -1104,23 +1138,67 @@ function TestHelpers.SetupOptionsGlobals()
f.SetWordWrap = noop
f.SetJustifyH = noop
f.SetJustifyV = noop
- f.SetColorRGB = noop
+ f.SetColorRGB = function(_, r, g, b, a)
+ textColor = { r, g, b, a or 1 }
+ end
f.SetColorTexture = noop
- f.SetTexture = noop
+ f.SetTexture = function(_, newTexture)
+ texture = newTexture
+ end
+ f.GetTexture = function()
+ return texture
+ end
+ f.SetDesaturated = function(_, isDesaturated)
+ desaturated = not not isDesaturated
+ end
+ f.IsDesaturated = function()
+ return desaturated
+ end
+ f.SetVertexColor = function(_, r, g, b, a)
+ vertexColor = { r, g, b, a or 1 }
+ end
+ f.GetVertexColor = function()
+ return vertexColor[1], vertexColor[2], vertexColor[3], vertexColor[4]
+ end
f.SetFontObject = function(_, newFontObject)
fontObject = newFontObject
end
f.GetFontObject = function()
return fontObject
end
- f.SetFocus = noop
- f.ClearFocus = noop
- f.HighlightText = noop
+ f.SetFocus = function(self)
+ if focusedFrame and focusedFrame ~= self and focusedFrame.ClearFocus then
+ focusedFrame:ClearFocus()
+ end
+ focusedFrame = self
+ hasFocus = true
+ end
+ f.ClearFocus = function(self)
+ if focusedFrame == self then
+ focusedFrame = nil
+ end
+ hasFocus = false
+ end
+ f.HasFocus = function()
+ return hasFocus
+ end
+ f.HighlightText = function()
+ highlighted = true
+ end
+ f.IsTextHighlighted = function()
+ return highlighted
+ end
f.SetBackdrop = noop
f.SetBackdropBorderColor = noop
f.SetMinMaxValues = noop
f.SetValueStep = noop
f.SetObeyStepOnDrag = noop
+ f.SetTextColor = function(_, r, g, b, a)
+ textColor = { r, g, b, a or 1 }
+ end
+ f.GetTextColor = function()
+ return textColor[1], textColor[2], textColor[3], textColor[4]
+ end
f.RegisterCallback = function(self, event, fn, owner)
self.callbacks[event] = self.callbacks[event] or {}
self.callbacks[event][#self.callbacks[event] + 1] = { fn = fn, owner = owner }
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index bcd56cbe..979a4711 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -11,6 +11,7 @@ describe("BuffBarsOptions", function()
local SpellColors
local SB
local ns
+ local printedMessages
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
@@ -34,6 +35,7 @@ describe("BuffBarsOptions", function()
"time",
"InCombatLockdown",
"IsInInstance",
+ "IsControlKeyDown",
"LibStub",
"CreateFromMixins",
"SettingsListElementInitializer",
@@ -81,6 +83,9 @@ describe("BuffBarsOptions", function()
_G.IsInInstance = function()
return false
end
+ _G.IsControlKeyDown = function()
+ return false
+ end
ns.Constants = {}
ns.FrameUtil = {
@@ -108,6 +113,10 @@ describe("BuffBarsOptions", function()
ns.DebugAssert = function() end
ns.Log = function() end
+ printedMessages = {}
+ ns.Print = function(message)
+ printedMessages[#printedMessages + 1] = message
+ end
-- Load library
local lsmw = LibStub("LibLSMSettingsWidgets-1.0", true) or TestHelpers.SetupLibSettingsBuilder()
@@ -136,6 +145,8 @@ describe("BuffBarsOptions", function()
return {}
end,
},
+ ConfirmReloadUI = function() end,
+ ShowConfirmDialog = function() end,
NewModule = function(_, name)
return { moduleName = name }
end,
@@ -275,4 +286,202 @@ describe("BuffBarsOptions", function()
assert.is_true(state.show)
assert.is_false(state.enabled)
end)
+
+ it("_BuildSpellColorKeyTooltipLines includes every available key", function()
+ local lines = BuffBarsOptions._BuildSpellColorKeyTooltipLines(
+ SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001)
+ )
+
+ assert.are.same({
+ "Spell name: Immolation Aura",
+ "Spell ID: 258920",
+ "Cooldown ID: 77",
+ "Texture File ID: 9001",
+ }, lines)
+ end)
+
+ it("_HasRowsNeedingReconcile detects rows missing any identifying key", function()
+ assert.is_false(BuffBarsOptions._HasRowsNeedingReconcile({
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001) },
+ }))
+
+ assert.is_true(BuffBarsOptions._HasRowsNeedingReconcile({
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
+ }))
+
+ assert.is_true(BuffBarsOptions._HasRowsNeedingReconcile({
+ { key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
+ }))
+ end)
+
+ it("ctrl-hovering a spell color row shows all keys for that row", function()
+ local key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001)
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+ local control = CreateFrame("Frame")
+
+ _G.IsControlKeyDown = function()
+ return true
+ end
+
+ frame._spellColorListView._initFn(control, { key = key, textureFileID = 9001 })
+ control.hooks.OnEnter[1](control)
+
+ assert.are.equal("Spell color keys", _G.GameTooltip._title)
+ assert.are.same({
+ "Spell name: Immolation Aura",
+ "Spell ID: 258920",
+ "Cooldown ID: 77",
+ "Texture File ID: 9001",
+ }, _G.GameTooltip._lines)
+ assert.is_true(_G.GameTooltip._shown)
+ end)
+
+ it("spell colors canvas disables reconcile when every row already has all identifying keys", function()
+ ns.SpellColors.GetAllColorEntries = function()
+ return {
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001) },
+ }
+ end
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+
+ frame:RefreshSpellList()
+
+ assert.is_false(frame._reconcileButton:IsEnabled())
+ assert.is_false(frame._removeStaleButton:IsEnabled())
+ end)
+
+ it("spell colors canvas enables reconcile and remove stale for incomplete rows", function()
+ ns.SpellColors.GetAllColorEntries = function()
+ return {
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
+ }
+ end
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+
+ frame:RefreshSpellList()
+
+ assert.is_true(frame._reconcileButton:IsEnabled())
+ assert.is_true(frame._removeStaleButton:IsEnabled())
+ end)
+
+ it("spell colors canvas disables reconcile in restricted areas", function()
+ _G.IsInInstance = function()
+ return true, "party"
+ end
+ ns.SpellColors.GetAllColorEntries = function()
+ return {
+ { key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
+ }
+ end
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+
+ frame:RefreshSpellList()
+
+ assert.is_false(frame._reconcileButton:IsEnabled())
+ assert.is_false(frame._removeStaleButton:IsEnabled())
+ end)
+
+ it("spell colors canvas reconcile button uses ConfirmReloadUI for unnamed rows", function()
+ local confirmText
+
+ ns.SpellColors.GetAllColorEntries = function()
+ return {
+ { key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
+ }
+ end
+ ns.Addon.ConfirmReloadUI = function(_, text)
+ confirmText = text
+ end
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+ local onClick = assert(frame._reconcileButton:GetScript("OnClick"))
+
+ frame:RefreshSpellList()
+ assert.is_true(frame._reconcileButton:IsEnabled())
+
+ onClick(frame._reconcileButton)
+
+ assert.are.equal(ns.L["SPELL_COLORS_SECRET_NAMES_DESC"], confirmText)
+ end)
+
+ it("spell colors canvas remove stale button shows the configured tooltip", function()
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+
+ frame._removeStaleButton:GetScript("OnEnter")(frame._removeStaleButton)
+
+ assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], _G.GameTooltip._title)
+ assert.is_true(_G.GameTooltip._shown)
+ end)
+
+ it("spell colors canvas remove stale button confirms, removes stale entries, prints, and refreshes", function()
+ local popupKey
+ local popupText
+ local acceptText
+ local cancelText
+ local onAccept
+ local scheduledReason
+
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
+ end
+ ns.Addon.ShowConfirmDialog = function(_, key, text, button1, button2, acceptFn)
+ popupKey = key
+ popupText = text
+ acceptText = button1
+ cancelText = button2
+ onAccept = acceptFn
+ end
+
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+
+ BuffBarsOptions.RegisterSettings(SB)
+
+ local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
+ local frame = assert(spellColorsCategory._frame)
+ local onClick = assert(frame._removeStaleButton:GetScript("OnClick"))
+
+ frame:RefreshSpellList()
+ assert.is_true(frame._removeStaleButton:IsEnabled())
+
+ onClick(frame._removeStaleButton)
+
+ assert.are.equal("ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS", popupKey)
+ assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], popupText)
+ assert.are.equal(ns.L["REMOVE"], acceptText)
+ assert.are.equal(ns.L["SPELL_COLORS_DONT_REMOVE"], cancelText)
+ assert.is_function(onAccept)
+
+ onAccept()
+
+ assert.are.same({}, ns.SpellColors.GetAllColorEntries())
+ assert.are.same({
+ ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("Immolation Aura"),
+ }, printedMessages)
+ assert.are.equal("OptionsChanged", scheduledReason)
+ assert.is_false(frame._removeStaleButton:IsEnabled())
+ end)
end)
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index e88f617d..3e8612f6 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -149,6 +149,22 @@ describe("ExtraIconsOptions data helpers", function()
ExtraIconsOptions._addStackKey(profile, "main", "healthstones")
assert.are.equal(1, #profile.extraIcons.viewers.main)
end)
+
+ it("skips duplicate builtin entries across viewers", function()
+ local profile = {
+ extraIcons = {
+ viewers = {
+ utility = { { stackKey = "trinket1" } },
+ main = {},
+ },
+ },
+ }
+
+ ExtraIconsOptions._addStackKey(profile, "main", "trinket1")
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+ end)
end)
describe("_addRacial", function()
@@ -177,6 +193,22 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.equal("spell", entry.kind)
assert.are.same({ 100, 200 }, entry.ids)
end)
+
+ it("skips duplicate custom entries across viewers", function()
+ local profile = {
+ extraIcons = {
+ viewers = {
+ utility = { { kind = "spell", ids = { 12345 } } },
+ main = {},
+ },
+ },
+ }
+
+ ExtraIconsOptions._addCustomEntry(profile, "main", "spell", { 12345 })
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+ end)
end)
describe("_setEntryDisabled", function()
@@ -304,6 +336,22 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.equal(1, #profile.extraIcons.viewers.utility)
assert.are.equal(0, #profile.extraIcons.viewers.main)
end)
+
+ it("is a no-op when the target viewer already has the same entry", function()
+ local profile = {
+ extraIcons = {
+ viewers = {
+ utility = { { kind = "spell", ids = { 12345 } } },
+ main = { { kind = "spell", ids = { 12345 } } },
+ },
+ },
+ }
+
+ ExtraIconsOptions._moveEntry(profile, "utility", "main", 1)
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ end)
end)
describe("_parseIds", function()
@@ -615,14 +663,20 @@ describe("ExtraIconsOptions data helpers", function()
describe("_buildViewerRows", function()
local savedUnitRace
+ local savedIsPlayerSpell
before_each(function()
savedUnitRace = _G.UnitRace
+ savedIsPlayerSpell = _G.IsPlayerSpell
_G.UnitRace = function() return "Human", "Human", 1 end
+ _G.IsPlayerSpell = function()
+ return false
+ end
end)
after_each(function()
_G.UnitRace = savedUnitRace
+ _G.IsPlayerSpell = savedIsPlayerSpell
end)
it("adds builtin and current-racial placeholders to utility when absent", function()
@@ -654,6 +708,40 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.equal("entry", rows[1].rowType)
assert.are.equal("trinket1", rows[1].displayEntry.stackKey)
end)
+
+ it("keeps disabled builtins in default order", function()
+ local viewers = {
+ utility = {},
+ main = {
+ { stackKey = "healthstones", disabled = true },
+ { kind = "spell", ids = { 59752 } },
+ { stackKey = "trinket1", disabled = true },
+ },
+ }
+
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, "main")
+
+ assert.are.equal("Spell 59752", ExtraIconsOptions._getEntryName(rows[1].displayEntry))
+ assert.are.equal("trinket1", rows[2].displayEntry.stackKey)
+ assert.are.equal("healthstones", rows[3].displayEntry.stackKey)
+ end)
+
+ it("falls back to known racial spells when UnitRace lookup misses", function()
+ local viewers = {
+ utility = {},
+ main = {},
+ }
+
+ _G.UnitRace = function() return "Unknown", "Unknown", 99 end
+ _G.IsPlayerSpell = function(spellId)
+ return spellId == 59752
+ end
+
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
+
+ assert.are.equal("racialPlaceholder", rows[#rows].rowType)
+ assert.are.equal(59752, rows[#rows].spellId)
+ end)
end)
end)
@@ -712,6 +800,14 @@ describe("ExtraIconsOptions settings page", function()
return nil
end
+ local function getVisibleRowLabels(viewerKey)
+ local labels = {}
+ for _, row in ipairs(getVisibleRows(viewerKey)) do
+ labels[#labels + 1] = row._label:GetText()
+ end
+ return labels
+ end
+
local function getDraftRow(viewerKey)
refresh()
return ns.ExtraIconsOptions._viewerCanvas._viewerDraftRows[viewerKey]
@@ -744,8 +840,10 @@ describe("ExtraIconsOptions settings page", function()
assert.is_table(vc._viewerDraftRows)
assert.is_table(vc._viewerHeaders)
assert.is_table(vc._viewerEmptyLabels)
+ assert.is_not_nil(vc._legendLabel)
assert.is_not_nil(vc._viewerHeaders.utility._title)
assert.is_not_nil(vc._viewerHeaders.main._title)
+ assert.are.equal(ns.L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"], vc._legendLabel:GetText())
assert.are.equal(ns.L["UTILITY_VIEWER_ICONS"], vc._viewerHeaders.utility._title:GetText())
assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], vc._viewerHeaders.main._title:GetText())
end)
@@ -775,6 +873,7 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("Test Spell", draftRow._previewLabel:GetText())
assert.is_true(draftRow._previewLabel:IsShown())
assert.is_true(draftRow._addBtn:IsShown())
+ assert.is_true(draftRow._addBtn:IsEnabled())
draftRow._addBtn:GetScript("OnClick")()
@@ -784,7 +883,7 @@ describe("ExtraIconsOptions settings page", function()
assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
end)
- it("shows pending draft resolution with ellipsis and no add button", function()
+ it("shows pending draft resolution with ellipsis and a disabled add button", function()
_G.C_Item = {
DoesItemExistByID = function(itemId)
return itemId == 777
@@ -805,7 +904,45 @@ describe("ExtraIconsOptions settings page", function()
draftRow = getDraftRow("utility")
assert.are.equal("...", draftRow._previewLabel:GetText())
assert.is_true(draftRow._previewLabel:IsShown())
- assert.is_false(draftRow._addBtn:IsShown())
+ assert.is_true(draftRow._addBtn:IsShown())
+ assert.is_false(draftRow._addBtn:IsEnabled())
+ end)
+
+ it("auto-resolves pending item drafts when item data finishes loading", function()
+ local itemNames = {}
+
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 777
+ end,
+ GetItemNameByID = function(itemId)
+ return itemNames[itemId]
+ end,
+ GetItemIconByID = function(itemId)
+ return itemId == 777 and "item-tex" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+
+ local draftRow = getDraftRow("utility")
+ draftRow._typeBtn:GetScript("OnClick")()
+ setDraftText("utility", "777")
+
+ assert.is_true(ns.ExtraIconsOptions._itemLoadFrame:IsEventRegistered("GET_ITEM_INFO_RECEIVED"))
+ assert.are.equal("...", draftRow._previewLabel:GetText())
+
+ itemNames[777] = "Loaded Item"
+ ns.ExtraIconsOptions._itemLoadFrame:GetScript("OnEvent")(
+ ns.ExtraIconsOptions._itemLoadFrame,
+ "GET_ITEM_INFO_RECEIVED",
+ 777,
+ true
+ )
+
+ draftRow = getDraftRow("utility")
+ assert.are.equal("Loaded Item", draftRow._previewLabel:GetText())
+ assert.is_true(draftRow._addBtn:IsShown())
+ assert.is_true(draftRow._addBtn:IsEnabled())
end)
it("disables builtin rows instead of removing them", function()
@@ -824,6 +961,33 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("GameFontDisable", row._label:GetFontObject())
end)
+ it("keeps disabled builtins in default order and locks their movement", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = {}
+ profile.extraIcons.viewers.main = {
+ { stackKey = "healthstones", disabled = true },
+ { kind = "spell", ids = { 12345 } },
+ { stackKey = "trinket1", disabled = true },
+ }
+
+ refresh()
+
+ assert.are.same({ "Test Spell", "Trinket 1", "Healthstones" }, getVisibleRowLabels("main"))
+
+ local trinketRow = findVisibleRowByText("main", "Trinket 1")
+ assert.is_not_nil(trinketRow)
+ assert.is_false(trinketRow._upBtn:IsEnabled())
+ assert.is_false(trinketRow._downBtn:IsEnabled())
+ assert.is_false(trinketRow._moveBtn:IsEnabled())
+ end)
+
it("renders missing builtin rows as disabled placeholders in utility", function()
profile.extraIcons.viewers.utility = {}
profile.extraIcons.viewers.main = {}
@@ -877,6 +1041,50 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("+", row._deleteBtn:GetText())
end)
+ it("shows the current racial placeholder when UnitRace lookup misses but the racial is known", function()
+ _G.UnitRace = function() return "Unknown", "Unknown", 99 end
+ _G.IsPlayerSpell = function(spellId)
+ return spellId == 59752
+ end
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ if spellId == 59752 then return "Every Man for Himself" end
+ return nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 59752 and "racial-tex" or nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = {}
+ profile.extraIcons.viewers.main = {}
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Every Man for Himself")
+ assert.is_not_nil(row)
+ assert.are.equal("+", row._deleteBtn:GetText())
+ end)
+
+ it("blocks duplicate inline additions and shows where the entry already exists", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
+ profile.extraIcons.viewers.main = {}
+
+ local draftRow = setDraftText("main", "12345")
+
+ assert.are.equal(ns.L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(ns.L["UTILITY_VIEWER_SHORT"]), draftRow._previewLabel:GetText())
+ assert.is_true(draftRow._addBtn:IsShown())
+ assert.is_false(draftRow._addBtn:IsEnabled())
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+ end)
+
it("does not display racials from other races", function()
_G.C_Spell = {
GetSpellName = function(spellId)
@@ -917,6 +1125,34 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(1, #profile.extraIcons.viewers.main)
end)
+ it("blocks moving an entry into a viewer that already has the same entry", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
+ profile.extraIcons.viewers.main = { { kind = "spell", ids = { 12345 } } }
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Test Spell")
+ assert.is_false(row._moveBtn:IsEnabled())
+ row._moveBtn:GetScript("OnEnter")(row._moveBtn)
+ assert.are.equal(
+ ns.L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(ns.L["MAIN_VIEWER_SHORT"]),
+ _G.GameTooltip._title
+ )
+
+ row._moveBtn:GetScript("OnClick")()
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ end)
+
it("rebinds whole-row mouseover handlers on refresh", function()
refresh()
@@ -944,11 +1180,146 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(37, x)
end)
+ it("adds extra spacing between viewer rows", function()
+ refresh()
+
+ local rows = getVisibleRows("utility")
+ local _, _, _, _, firstY = rows[1]:GetPoint(1)
+ local _, _, _, _, secondY = rows[2]:GetPoint(1)
+
+ assert.are.equal(-30, secondY - firstY)
+ end)
+
it("creates inline draft rows at the same alignment", function()
local row = getDraftRow("utility")
local point, _, _, x = row:GetPoint(1)
assert.are.equal("TOPLEFT", point)
assert.are.equal(37, x)
end)
+
+ it("shows placeholder text that tracks draft type and focus", function()
+ local row = getDraftRow("utility")
+
+ assert.are.equal(ns.L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"], row._editBoxPlaceholder:GetText())
+ assert.is_true(row._editBoxPlaceholder:IsShown())
+ assert.is_false(row._addBtn:IsEnabled())
+
+ row._editBox:SetFocus()
+ row._editBox:GetScript("OnEditFocusGained")(row._editBox)
+ assert.is_false(row._editBoxPlaceholder:IsShown())
+
+ row._editBox:GetScript("OnEditFocusLost")(row._editBox)
+ assert.is_true(row._editBoxPlaceholder:IsShown())
+
+ row._typeBtn:GetScript("OnClick")()
+
+ row = getDraftRow("utility")
+ assert.are.equal(ns.L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"], row._editBoxPlaceholder:GetText())
+ assert.is_true(row._editBoxPlaceholder:IsShown())
+ assert.is_false(row._editBoxHasFocus)
+ end)
+
+ it("supports keyboard-friendly draft entry flow", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+
+ local row = getDraftRow("main")
+ row._editBox:SetFocus()
+ row._editBox:SetText("12345")
+ row._editBox:GetScript("OnTextChanged")(row._editBox)
+ row._editBox:GetScript("OnEnterPressed")(row._editBox)
+
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ assert.is_true(row._editBox:HasFocus())
+ assert.is_true(row._editBox:IsTextHighlighted())
+
+ row._editBox:GetScript("OnTabPressed")(row._editBox)
+
+ assert.are.equal("item", ns.ExtraIconsOptions._draftStates.main.kind)
+ assert.is_true(row._editBox:HasFocus())
+
+ row._editBox:GetScript("OnEscapePressed")(row._editBox)
+ assert.is_false(row._editBox:HasFocus())
+ end)
+
+ it("shows explanatory tooltips for special rows", function()
+ profile.extraIcons.viewers.utility = {}
+ profile.extraIcons.viewers.main = {}
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Trinket 1")
+ row:GetScript("OnEnter")(row)
+
+ assert.are.equal("Trinket 1", _G.GameTooltip._title)
+ assert.are.equal(ns.L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], _G.GameTooltip._lines[1])
+ end)
+
+ it("appends spell IDs to spell-entry tooltip titles", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function()
+ return nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Test Spell")
+ row:GetScript("OnEnter")(row)
+
+ assert.are.equal("Test Spell (spell ID 12345)", _G.GameTooltip._title)
+ end)
+
+ it("appends item IDs to item-entry tooltip titles", function()
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 99999
+ end,
+ GetItemNameByID = function(itemId)
+ return itemId == 99999 and "Test Item" or nil
+ end,
+ GetItemIconByID = function()
+ return nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
+ profile.extraIcons.viewers.utility = { { kind = "item", ids = { { itemID = 99999 } } } }
+
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Test Item")
+ row:GetScript("OnEnter")(row)
+
+ assert.are.equal("Test Item (item ID 99999)", _G.GameTooltip._title)
+ end)
+
+ it("uses Hide for active built-in row button tooltips", function()
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Trinket 1")
+ row._deleteBtn:GetScript("OnEnter")(row._deleteBtn)
+
+ assert.are.equal(ns.L["EXTRA_ICONS_HIDE_TOOLTIP"], _G.GameTooltip._title)
+ end)
+
+ it("does not add built-in enabled text to active built-in row tooltips", function()
+ refresh()
+
+ local row = findVisibleRowByText("utility", "Trinket 1")
+ row:GetScript("OnEnter")(row)
+
+ assert.are.equal("Trinket 1", _G.GameTooltip._title)
+ assert.are.same({}, _G.GameTooltip._lines)
+ end)
end)
end)
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index 9a584228..de7e22b2 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -6,6 +6,11 @@ local _, ns = ...
local C = ns.Constants
local L = ns.L
+local REMOVE_STALE_SPELL_COLORS_POPUP = "ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS"
+local SPELL_COLORS_HEADER_BUTTON_WIDTH = 100
+local SPELL_COLORS_HEADER_BUTTON_HEIGHT = 22
+local SPELL_COLORS_HEADER_BUTTON_SPACING = 8
+
--- Generates the merged list of spell color rows from spell color entries.
---@param entries { key: ECM_SpellColorKey }[]|nil
---@return { key: ECM_SpellColorKey, textureFileID: number|nil }[]
@@ -67,6 +72,124 @@ local function getSecretNameFooterState(rows)
}
end
+--- Returns true when the key is missing one or more identifying fields.
+---@param key ECM_SpellColorKey|table|nil
+---@return boolean
+local function isIncompleteSpellColorKey(key)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ return normalized ~= nil
+ and (normalized.spellName == nil
+ or normalized.spellID == nil
+ or normalized.cooldownID == nil
+ or normalized.textureFileID == nil)
+end
+
+--- Returns whether any row is missing one or more identifying fields.
+---@param rows { key: ECM_SpellColorKey }[]|nil
+---@return boolean
+local function hasRowsNeedingReconcile(rows)
+ for _, row in ipairs(rows or {}) do
+ if isIncompleteSpellColorKey(row and row.key) then
+ return true
+ end
+ end
+
+ return false
+end
+
+---@param rows { key: ECM_SpellColorKey }[]|nil
+---@return { key: ECM_SpellColorKey }[]
+local function collectIncompleteSpellColorRows(rows)
+ local incompleteRows = {}
+
+ for _, row in ipairs(rows or {}) do
+ if isIncompleteSpellColorKey(row and row.key) then
+ incompleteRows[#incompleteRows + 1] = row
+ end
+ end
+
+ return incompleteRows
+end
+
+---@param owner Frame
+---@param text string
+local function setSimpleTooltip(owner, text)
+ owner:SetScript("OnEnter", function(self)
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(text, 1, 1, 1, true)
+ GameTooltip:Show()
+ end)
+ owner:SetScript("OnLeave", function()
+ GameTooltip_Hide()
+ end)
+end
+
+---@param key ECM_SpellColorKey|table|nil
+---@return string
+local function getSpellColorRowName(key)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ local primaryKey = normalized and normalized.primaryKey or nil
+
+ if type(primaryKey) == "string" then
+ return primaryKey
+ end
+
+ return "Bar (" .. tostring(primaryKey) .. ")"
+end
+
+---@param key ECM_SpellColorKey|table|nil
+---@return string[]
+local function buildSpellColorKeyTooltipLines(key)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ if not normalized then
+ return {}
+ end
+
+ local lines = {}
+
+ local function addLine(formatString, value)
+ local valueType = type(value)
+ if valueType == "string" or valueType == "number" then
+ lines[#lines + 1] = string.format(formatString, value)
+ end
+ end
+
+ addLine(L["SPELL_COLORS_KEY_SPELL_NAME"], normalized.spellName)
+ addLine(L["SPELL_COLORS_KEY_SPELL_ID"], normalized.spellID)
+ addLine(L["SPELL_COLORS_KEY_COOLDOWN_ID"], normalized.cooldownID)
+ addLine(L["SPELL_COLORS_KEY_TEXTURE_FILE_ID"], normalized.textureFileID)
+
+ return lines
+end
+
+---@param owner Frame
+---@param data { key: ECM_SpellColorKey }|nil
+local function maybeShowSpellColorKeyTooltip(owner, data)
+ if not IsControlKeyDown() then
+ return
+ end
+
+ local lines = buildSpellColorKeyTooltipLines(type(data) == "table" and data.key)
+ if #lines == 0 then
+ return
+ end
+
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], 1, 1, 1)
+
+ for _, line in ipairs(lines) do
+ GameTooltip:AddLine(line, nil, nil, nil, true)
+ end
+
+ GameTooltip:Show()
+end
+
--------------------------------------------------------------------------------
-- Canvas Frame for Spell Colors
--------------------------------------------------------------------------------
@@ -83,9 +206,67 @@ local function createSpellColorCanvas(SB, subcatName)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
+ local function reconcileSpellColors()
+ ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
+ end
+
+ local function removeStaleSpellColors()
+ local staleRows = collectIncompleteSpellColorRows(buildSpellColorRows(ns.SpellColors.GetAllColorEntries()))
+ if #staleRows == 0 then
+ return
+ end
+
+ ns.Addon:ShowConfirmDialog(
+ REMOVE_STALE_SPELL_COLORS_POPUP,
+ L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
+ L["REMOVE"],
+ L["SPELL_COLORS_DONT_REMOVE"],
+ function()
+ local staleKeys = {}
+ for _, row in ipairs(staleRows) do
+ staleKeys[#staleKeys + 1] = row.key
+ end
+
+ local removedKeys = ns.SpellColors.RemoveEntriesByKeys(staleKeys)
+ for _, key in ipairs(removedKeys) do
+ ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key)))
+ end
+
+ frame:RefreshSpellList()
+ if #removedKeys > 0 then
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ end
+ end
+ )
+ end
+
-- Header — uses SettingsListTemplate's built-in Title, divider, and DefaultsButton
local headerRow = layout:AddHeader(subcatName)
local defaultsBtn = headerRow._defaultsButton
+ local reconcileBtn = CreateFrame("Button", nil, headerRow, "UIPanelButtonTemplate")
+ local removeStaleBtn = CreateFrame("Button", nil, headerRow, "UIPanelButtonTemplate")
+
+ reconcileBtn:SetSize(SPELL_COLORS_HEADER_BUTTON_WIDTH, SPELL_COLORS_HEADER_BUTTON_HEIGHT)
+ reconcileBtn:SetPoint("RIGHT", defaultsBtn, "LEFT", -SPELL_COLORS_HEADER_BUTTON_SPACING, 0)
+ reconcileBtn:SetText(L["SPELL_COLORS_RECONCILE_BUTTON"])
+ reconcileBtn:SetScript("OnClick", function()
+ if not reconcileBtn:IsEnabled() then
+ return
+ end
+ reconcileSpellColors()
+ end)
+
+ removeStaleBtn:SetSize(SPELL_COLORS_HEADER_BUTTON_WIDTH, SPELL_COLORS_HEADER_BUTTON_HEIGHT)
+ removeStaleBtn:SetPoint("RIGHT", reconcileBtn, "LEFT", -SPELL_COLORS_HEADER_BUTTON_SPACING, 0)
+ removeStaleBtn:SetText(L["SPELL_COLORS_REMOVE_STALE_BUTTON"])
+ removeStaleBtn:SetScript("OnClick", function()
+ if not removeStaleBtn:IsEnabled() then
+ return
+ end
+ removeStaleSpellColors()
+ end)
+ setSimpleTooltip(removeStaleBtn, L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"])
+
defaultsBtn:SetText(SETTINGS_DEFAULTS)
defaultsBtn:SetScript("OnClick", function()
if ns.Addon.BuffBars:IsEditLocked() then
@@ -169,6 +350,9 @@ local function createSpellColorCanvas(SB, subcatName)
frame._secretNameDescRow = secretNameDescRow
frame._secretNameReloadButtonRow = secretNameButtonRow
frame._secretNameReloadButton = secretNameReloadButton
+ frame._reconcileButton = reconcileBtn
+ frame._removeStaleButton = removeStaleBtn
+ frame._spellColorListView = view
view:SetElementInitializer("SettingsColorSwatchControlTemplate", function(control, data)
-- Position label (matches SettingsListElementMixin:Init positioning)
@@ -180,8 +364,22 @@ local function createSpellColorCanvas(SB, subcatName)
control._ecmPositioned = true
end
- local colorKey = data.key.primaryKey
- local name = type(colorKey) == "string" and colorKey or ("Bar (" .. colorKey .. ")")
+ if not control._ecmSpellColorTooltipHooked then
+ if control.EnableMouse then
+ control:EnableMouse(true)
+ end
+ control:HookScript("OnEnter", function(self)
+ maybeShowSpellColorKeyTooltip(self, self._ecmSpellColorRowData)
+ end)
+ control:HookScript("OnLeave", function()
+ GameTooltip_Hide()
+ end)
+ control._ecmSpellColorTooltipHooked = true
+ end
+
+ control._ecmSpellColorRowData = data
+
+ local name = getSpellColorRowName(data.key)
local label = data.textureFileID and ("|T" .. data.textureFileID .. ":14:14|t " .. name) or name
control.Text:SetText(label)
@@ -207,6 +405,9 @@ local function createSpellColorCanvas(SB, subcatName)
function frame:RefreshSpellList()
local rows = buildSpellColorRows(ns.SpellColors.GetAllColorEntries())
local secretNameFooterState = getSecretNameFooterState(rows)
+ local hasIncompleteRows = hasRowsNeedingReconcile(rows)
+ local canReconcile = hasIncompleteRows and not isSpellColorsReloadRestricted()
+ local canRemoveStale = hasIncompleteRows and not isSpellColorsReloadRestricted()
dataProvider:Flush()
for _, row in ipairs(rows) do
@@ -236,6 +437,8 @@ local function createSpellColorCanvas(SB, subcatName)
defaultColorSwatch:SetColorRGB(dc.r, dc.g, dc.b)
defaultsBtn:SetEnabled(not locked)
+ reconcileBtn:SetEnabled(canReconcile)
+ removeStaleBtn:SetEnabled(canRemoveStale)
end
-- Blizzard's panel calls OnDefault on canvas frames during global defaults
@@ -256,8 +459,11 @@ local BuffBarsOptions = {}
ns.BuffBarsOptions = BuffBarsOptions
BuffBarsOptions._BuildSpellColorRows = buildSpellColorRows
BuffBarsOptions._HasUnlabeledBars = hasUnlabeledBars
+BuffBarsOptions._HasRowsNeedingReconcile = hasRowsNeedingReconcile
+BuffBarsOptions._CollectIncompleteSpellColorRows = collectIncompleteSpellColorRows
BuffBarsOptions._IsSpellColorsReloadRestricted = isSpellColorsReloadRestricted
BuffBarsOptions._GetSecretNameFooterState = getSecretNameFooterState
+BuffBarsOptions._BuildSpellColorKeyTooltipLines = buildSpellColorKeyTooltipLines
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
diff --git a/UI/Dialogs.lua b/UI/Dialogs.lua
index cd276a72..036a6ff9 100644
--- a/UI/Dialogs.lua
+++ b/UI/Dialogs.lua
@@ -237,6 +237,46 @@ local copyTextFrame
local migrationLogFrame
local importFrame
+--- Shows a generic static confirmation popup with custom button labels.
+---@param popupKey string
+---@param text string
+---@param button1 string|nil
+---@param button2 string|nil
+---@param onAccept fun()|nil
+---@param onCancel fun()|nil
+function mod:ShowConfirmDialog(popupKey, text, button1, button2, onAccept, onCancel)
+ if not popupKey or popupKey == "" then
+ return
+ end
+
+ if not StaticPopupDialogs[popupKey] then
+ StaticPopupDialogs[popupKey] = {
+ text = text or "",
+ button1 = button1 or YES or "Yes",
+ button2 = button2 or NO or "No",
+ OnAccept = function(_, data)
+ if data and data.onAccept then
+ data.onAccept()
+ end
+ end,
+ OnCancel = function(_, data)
+ if data and data.onCancel then
+ data.onCancel()
+ end
+ end,
+ timeout = 0,
+ whileDead = 1,
+ hideOnEscape = 1,
+ preferredIndex = C.POPUP_PREFERRED_INDEX,
+ }
+ end
+
+ StaticPopupDialogs[popupKey].text = text or ""
+ StaticPopupDialogs[popupKey].button1 = button1 or YES or "Yes"
+ StaticPopupDialogs[popupKey].button2 = button2 or NO or "No"
+ StaticPopup_Show(popupKey, nil, nil, { onAccept = onAccept, onCancel = onCancel })
+end
+
function mod:ShowReleasePopup(force)
local popupVersion = C.RELEASE_POPUP_VERSION
local body = L["WHATS_NEW_BODY"]
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index b8a768ee..04eefce5 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -21,6 +21,9 @@ local DRAFT_ADD_BUTTON_WIDTH = 44
local TOOLTIP_ITEM_ICON_SIZE = 14
local TOOLTIP_QUALITY_ICON_SIZE = 14
local SETTINGS_LABEL_X = 37
+local SPECIAL_ROWS_LEGEND_HEIGHT = 24
+local VIEWER_ROW_SPACING = 4
+local VIEWER_CANVAS_HEIGHT = 448
local DEFAULT_SPECIAL_VIEWER = "utility"
local DRAFT_PENDING_TEXT = "..."
local VIEWER_ORDER = { "utility", "main" }
@@ -28,9 +31,40 @@ local VIEWER_LABELS = {
utility = "UTILITY_VIEWER_ICONS",
main = "MAIN_VIEWER_ICONS",
}
+local RACIAL_ALIASES = {
+ undead = "Scourge",
+ earthen = "EarthenDwarf",
+}
+
+local RACIAL_ABILITIES_BY_NORMALIZED_KEY = {}
+for raceKey, racialEntry in pairs(RACIAL_ABILITIES) do
+ if type(raceKey) == "string" then
+ local normalizedKey = raceKey:gsub("[^%a%d]", ""):lower()
+ RACIAL_ABILITIES_BY_NORMALIZED_KEY[normalizedKey] = racialEntry
+ end
+end
+
+local function getViewerShortLabel(viewerKey)
+ return viewerKey == "utility" and L["UTILITY_VIEWER_SHORT"] or L["MAIN_VIEWER_SHORT"]
+end
+
+local function getBuiltinOrderIndex(stackKey)
+ for index, builtinKey in ipairs(BUILTIN_STACK_ORDER) do
+ if builtinKey == stackKey then
+ return index
+ end
+ end
+
+ return nil
+end
+
+local function isDisabledBuiltinEntry(entry)
+ return entry and entry.stackKey and entry.disabled and getBuiltinOrderIndex(entry.stackKey) ~= nil
+end
local ExtraIconsOptions = {}
ns.ExtraIconsOptions = ExtraIconsOptions
+ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
--------------------------------------------------------------------------------
-- Data Helpers
@@ -48,9 +82,44 @@ function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
return false
end
+local function lookupRacialEntryByRaceKey(raceKey)
+ if type(raceKey) ~= "string" or raceKey == "" then
+ return nil
+ end
+
+ local direct = RACIAL_ABILITIES[raceKey]
+ if direct then
+ return direct
+ end
+
+ local normalizedKey = raceKey:gsub("[^%a%d]", ""):lower()
+ local aliasKey = RACIAL_ALIASES[normalizedKey]
+ if aliasKey and RACIAL_ABILITIES[aliasKey] then
+ return RACIAL_ABILITIES[aliasKey]
+ end
+
+ return RACIAL_ABILITIES_BY_NORMALIZED_KEY[normalizedKey]
+end
+
+local function lookupKnownRacialEntry()
+ if type(IsPlayerSpell) ~= "function" then
+ return nil
+ end
+
+ for _, racialEntry in pairs(RACIAL_ABILITIES) do
+ if racialEntry and racialEntry.spellId and IsPlayerSpell(racialEntry.spellId) then
+ return racialEntry
+ end
+ end
+
+ return nil
+end
+
local function getCurrentRacialEntry()
local raceName, raceFile = UnitRace("player")
- return (raceFile and RACIAL_ABILITIES[raceFile]) or (raceName and RACIAL_ABILITIES[raceName])
+ return lookupRacialEntryByRaceKey(raceFile)
+ or lookupRacialEntryByRaceKey(raceName)
+ or lookupKnownRacialEntry()
end
local function getCurrentRacialSpellId()
@@ -67,6 +136,12 @@ local function getEntrySpellId(entry)
return type(first) == "table" and first.spellId or first
end
+local function markPendingItemLoad(itemId)
+ if itemId then
+ ExtraIconsOptions._pendingItemLoads[itemId] = true
+ end
+end
+
--- Check if a racial spellId is present in any viewer's entries.
function ExtraIconsOptions._isRacialPresent(viewers, spellId)
for _, entries in pairs(viewers) do
@@ -95,10 +170,12 @@ local function getItemDisplayName(itemId)
local name = C_Item.GetItemNameByID(itemId)
if name then
+ ExtraIconsOptions._pendingItemLoads[itemId] = nil
return name
end
if C_Item.DoesItemExistByID(itemId) then
+ markPendingItemLoad(itemId)
C_Item.RequestLoadItemDataByID(itemId)
return L["EXTRA_ICONS_ITEM_LOADING"]
end
@@ -151,17 +228,12 @@ local function buildTooltipLine(...)
return table.concat(parts, " ")
end
-local function setItemStackTooltip(owner, entry)
+local function addItemStackTooltipLines(entry)
local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
if not stack or stack.kind ~= "item" or not stack.ids or #stack.ids == 0 then
return false
end
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- GameTooltip:SetText(stack.label, 1, 1, 1)
GameTooltip:AddLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], nil, nil, nil, true)
for _, itemEntry in ipairs(stack.ids) do
@@ -181,7 +253,6 @@ local function setItemStackTooltip(owner, entry)
)
end
- GameTooltip:Show()
return true
end
@@ -205,7 +276,8 @@ function ExtraIconsOptions._getEntryName(entry)
if entry.kind == "spell" and entry.ids then
local first = entry.ids[1]
local spellId = type(first) == "table" and first.spellId or first
- local name = spellId and C_Spell.GetSpellName(spellId)
+ local spellAPI = type(C_Spell) == "table" and C_Spell or nil
+ local name = spellId and spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(spellId)
return name or ("Spell " .. tostring(spellId))
end
if entry.kind == "item" and entry.ids then
@@ -215,6 +287,27 @@ function ExtraIconsOptions._getEntryName(entry)
return "Unknown"
end
+local function getEntryTooltipTitle(entry)
+ local name = ExtraIconsOptions._getEntryName(entry)
+ if type(entry) ~= "table" then
+ return name
+ end
+
+ if entry.kind == "spell" then
+ local spellId = getEntrySpellId(entry)
+ if spellId then
+ return string.format("%s (spell ID %s)", name, spellId)
+ end
+ elseif entry.kind == "item" and entry.ids and entry.ids[1] then
+ local itemId = getItemIdFromEntry(entry.ids[1])
+ if itemId then
+ return string.format("%s (item ID %s)", name, itemId)
+ end
+ end
+
+ return name
+end
+
--- Get display icon for a config entry.
function ExtraIconsOptions._getEntryIcon(entry)
if entry.stackKey then
@@ -233,7 +326,8 @@ function ExtraIconsOptions._getEntryIcon(entry)
if entry.kind == "spell" and entry.ids then
local first = entry.ids[1]
local spellId = type(first) == "table" and first.spellId or first
- return spellId and C_Spell.GetSpellTexture(spellId)
+ local spellAPI = type(C_Spell) == "table" and C_Spell or nil
+ return spellId and spellAPI and spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(spellId)
end
if entry.kind == "item" and entry.ids then
local first = entry.ids[1]
@@ -246,6 +340,9 @@ end
--- Add a predefined stack entry to a viewer.
function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
local viewers = profile.extraIcons.viewers
+ if ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ return
+ end
viewers[viewerKey] = viewers[viewerKey] or {}
viewers[viewerKey][#viewers[viewerKey] + 1] = { stackKey = stackKey }
end
@@ -253,6 +350,9 @@ end
--- Add a racial spell entry to a viewer.
function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
local viewers = profile.extraIcons.viewers
+ if ExtraIconsOptions._isRacialPresent(viewers, spellId) then
+ return
+ end
viewers[viewerKey] = viewers[viewerKey] or {}
viewers[viewerKey][#viewers[viewerKey] + 1] = { kind = "spell", ids = { spellId } }
end
@@ -269,6 +369,9 @@ function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
entry.ids[#entry.ids + 1] = id
end
end
+ if ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
+ return
+ end
viewers[viewerKey][#viewers[viewerKey] + 1] = entry
end
@@ -321,6 +424,9 @@ function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
local entries = profile.extraIcons.viewers[viewerKey]
if not entries then return end
local target = index + direction
+ while target >= 1 and target <= #entries and isDisabledBuiltinEntry(entries[target]) do
+ target = target + direction
+ end
if target < 1 or target > #entries then return end
entries[index], entries[target] = entries[target], entries[index]
end
@@ -329,6 +435,10 @@ end
function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
local from = profile.extraIcons.viewers[fromViewer]
if not from or index < 1 or index > #from then return end
+ local candidateEntry = from[index]
+ if ExtraIconsOptions._findDuplicateEntry(profile.extraIcons.viewers, candidateEntry, fromViewer, index) == toViewer then
+ return
+ end
local entry = table.remove(from, index)
local to = profile.extraIcons.viewers[toViewer] or {}
profile.extraIcons.viewers[toViewer] = to
@@ -380,12 +490,13 @@ function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
end
if kind == "spell" then
- local name = C_Spell.GetSpellName(id)
+ local spellAPI = type(C_Spell) == "table" and C_Spell or nil
+ local name = spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(id)
if not name then
return "invalid", nil, nil
end
- return "resolved", name, C_Spell.GetSpellTexture(id)
+ return "resolved", name, spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(id)
end
if kind == "item" then
@@ -396,9 +507,11 @@ function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
local name = C_Item.GetItemNameByID(id)
local icon = C_Item.GetItemIconByID(id)
if name then
+ ExtraIconsOptions._pendingItemLoads[id] = nil
return "resolved", name, icon
end
+ markPendingItemLoad(id)
C_Item.RequestLoadItemDataByID(id)
return "pending", nil, icon
@@ -419,13 +532,82 @@ function ExtraIconsOptions._resolveDraftEntryName(kind, text)
return name
end
+local function getEntryIdentityKey(entry)
+ if not entry then
+ return nil
+ end
+
+ if entry.stackKey then
+ return "stack:" .. tostring(entry.stackKey)
+ end
+
+ if entry.kind == "spell" and entry.ids and #entry.ids > 0 then
+ local parts = { "spell" }
+ for _, id in ipairs(entry.ids) do
+ local spellId = type(id) == "table" and id.spellId or id
+ parts[#parts + 1] = tostring(spellId)
+ end
+ return table.concat(parts, ":")
+ end
+
+ if entry.kind == "item" and entry.ids and #entry.ids > 0 then
+ local parts = { "item" }
+ for _, id in ipairs(entry.ids) do
+ parts[#parts + 1] = tostring(getItemIdFromEntry(id))
+ end
+ return table.concat(parts, ":")
+ end
+
+ return nil
+end
+
+function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ local candidateKey = getEntryIdentityKey(candidateEntry)
+ if not candidateKey then
+ return nil, nil
+ end
+
+ for viewerKey, entries in pairs(viewers) do
+ for index, entry in ipairs(entries) do
+ if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
+ and getEntryIdentityKey(entry) == candidateKey then
+ return viewerKey, index
+ end
+ end
+ end
+
+ return nil, nil
+end
+
+function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ local viewerKey = ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ return viewerKey ~= nil
+end
+
+local function buildDraftEntry(kind, id)
+ if not id then
+ return nil
+ end
+
+ if kind == "item" then
+ return { kind = "item", ids = { { itemID = id } } }
+ end
+
+ if kind == "spell" then
+ return { kind = "spell", ids = { id } }
+ end
+
+ return nil
+end
+
function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
- local rows = {}
+ local activeRows = {}
local entries = viewers[viewerKey] or {}
+ local disabledBuiltinRows = {}
for index, entry in ipairs(entries) do
if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
- rows[#rows + 1] = {
+ local rowData = {
rowType = "entry",
viewerKey = viewerKey,
index = index,
@@ -436,13 +618,31 @@ function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
isPlaceholder = false,
isDisabled = entry.disabled == true,
}
+
+ if isDisabledBuiltinEntry(entry) then
+ disabledBuiltinRows[entry.stackKey] = disabledBuiltinRows[entry.stackKey] or {}
+ disabledBuiltinRows[entry.stackKey][#disabledBuiltinRows[entry.stackKey] + 1] = rowData
+ else
+ activeRows[#activeRows + 1] = rowData
+ end
end
end
- if viewerKey == DEFAULT_SPECIAL_VIEWER then
- for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- rows[#rows + 1] = {
+ for activeIndex, rowData in ipairs(activeRows) do
+ rowData.activeIndex = activeIndex
+ rowData.activeCount = #activeRows
+ end
+
+ local rows = activeRows
+
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ local bucket = disabledBuiltinRows[stackKey]
+ if bucket then
+ for _, rowData in ipairs(bucket) do
+ rows[#rows + 1] = rowData
+ end
+ elseif viewerKey == DEFAULT_SPECIAL_VIEWER and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ rows[#rows + 1] = {
rowType = "builtinPlaceholder",
viewerKey = viewerKey,
stackKey = stackKey,
@@ -451,10 +651,11 @@ function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
isCurrentRacial = false,
isPlaceholder = true,
isDisabled = true,
- }
- end
+ }
end
+ end
+ if viewerKey == DEFAULT_SPECIAL_VIEWER then
local racialSpellId = getCurrentRacialSpellId()
if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
rows[#rows + 1] = {
@@ -486,12 +687,49 @@ end
local function setButtonTooltip(btn, text)
btn:SetScript("OnEnter", function(self)
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
GameTooltip:SetText(text, 1, 1, 1)
GameTooltip:Show()
end)
btn:SetScript("OnLeave", GameTooltip_Hide)
end
+local function addTooltipLine(text)
+ if text and text ~= "" then
+ GameTooltip:AddLine(text, nil, nil, nil, true)
+ end
+end
+
+local function showRowTooltip(owner, rowData)
+ if not rowData then
+ return
+ end
+
+ local displayEntry = rowData.displayEntry
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1)
+
+ if rowData.isBuiltin then
+ if rowData.isPlaceholder then
+ addTooltipLine(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
+ end
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ addTooltipLine(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
+ end
+
+ if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
+ addTooltipLine(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ end
+
+ addItemStackTooltipLines(displayEntry)
+ GameTooltip:Show()
+end
+
local function clearRowMouseover(row)
row:SetScript("OnEnter", nil)
row:SetScript("OnLeave", nil)
@@ -610,6 +848,12 @@ local function createDraftRow(parent)
row._editBox:SetTextInsets(6, 6, 0, 0)
end
+ row._editBoxPlaceholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ row._editBoxPlaceholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0)
+ row._editBoxPlaceholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0)
+ row._editBoxPlaceholder:SetJustifyH("LEFT")
+ row._editBoxPlaceholder:SetWordWrap(false)
+
row._previewIcon = row:CreateTexture(nil, "ARTWORK")
row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
row._previewIcon:SetSize(DRAFT_ENTRY_PREVIEW_ICON_SIZE, DRAFT_ENTRY_PREVIEW_ICON_SIZE)
@@ -625,7 +869,6 @@ local function createDraftRow(parent)
row._addBtn:SetSize(DRAFT_ADD_BUTTON_WIDTH, BTN_SIZE)
row._addBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
row._addBtn:SetText(L["ADD_ENTRY"])
- row._addBtn:Hide()
row._previewLabel:SetPoint("RIGHT", row._addBtn, "LEFT", -6, 0)
@@ -635,7 +878,7 @@ end
local function createViewerHeaderRow(parent, SB, text, headerHeight)
local row = CreateFrame("Frame", nil, parent)
row:SetHeight(headerHeight)
- row._title = SB.CreateHeaderTitle(row, text)
+ row._title = SB.CreateSubheaderTitle(row, text)
return row
end
@@ -645,12 +888,16 @@ end
local function createViewerListCanvas(SB, headerHeight)
local frame = CreateFrame("Frame")
- frame:SetHeight(400)
+ frame:SetHeight(VIEWER_CANVAS_HEIGHT)
frame._viewerRowPools = { utility = {}, main = {} }
frame._viewerDraftRows = {}
frame._viewerHeaders = {}
frame._viewerEmptyLabels = {}
+ frame._legendLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ frame._legendLabel:SetJustifyH("LEFT")
+ frame._legendLabel:SetWordWrap(true)
+ frame._legendLabel:SetText(L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"])
for _, vk in ipairs(VIEWER_ORDER) do
frame._viewerHeaders[vk] = createViewerHeaderRow(frame, SB, L[VIEWER_LABELS[vk]], headerHeight)
@@ -697,6 +944,26 @@ function ExtraIconsOptions.RegisterSettings(SB)
}
end
+ local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
+ if not itemLoadFrame then
+ itemLoadFrame = CreateFrame("Frame")
+ ExtraIconsOptions._itemLoadFrame = itemLoadFrame
+ end
+ if not itemLoadFrame._ecmHooked then
+ if itemLoadFrame.RegisterEvent then
+ itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
+ end
+ itemLoadFrame:SetScript("OnEvent", function(_, _, itemId)
+ if itemId and ExtraIconsOptions._pendingItemLoads[itemId] then
+ ExtraIconsOptions._pendingItemLoads[itemId] = nil
+ if ExtraIconsOptions._refresh then
+ ExtraIconsOptions._refresh()
+ end
+ end
+ end)
+ itemLoadFrame._ecmHooked = true
+ end
+
local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
@@ -730,12 +997,64 @@ function ExtraIconsOptions.RegisterSettings(SB)
return ExtraIconsOptions._resolveDraftEntryPreview(draftState.kind, draftState.idText)
end
+ local function getDraftPlaceholderText(draftState)
+ if draftState.kind == "spell" then
+ return L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"]
+ end
+
+ return L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
+ end
+
+ local function refreshDraftPlaceholder(row, viewerKey)
+ if not (row and row._editBoxPlaceholder) then
+ return
+ end
+
+ local draftState = draftStates[viewerKey]
+ local idText = draftState and draftState.idText or ""
+ row._editBoxPlaceholder:SetText(getDraftPlaceholderText(draftState))
+
+ if row._editBoxHasFocus or idText ~= "" then
+ row._editBoxPlaceholder:Hide()
+ else
+ row._editBoxPlaceholder:Show()
+ end
+ end
+
+ local function focusDraftEditBox(row)
+ if not (row and row._editBox) then
+ return
+ end
+
+ if row._editBox.SetFocus then
+ row._editBox:SetFocus()
+ end
+ row._editBoxHasFocus = true
+ refreshDraftPlaceholder(row, row._viewerKey)
+ if row._editBox.HighlightText then
+ row._editBox:HighlightText()
+ end
+ end
+
+ local function getDraftDuplicateInfo(viewerKey)
+ local draftState = draftStates[viewerKey]
+ local id = ExtraIconsOptions._parseSingleId(draftState.idText)
+ local entry = buildDraftEntry(draftState.kind, id)
+ local duplicateViewerKey = entry and ExtraIconsOptions._findDuplicateEntry(getViewers(), entry) or nil
+ return duplicateViewerKey ~= nil, duplicateViewerKey
+ end
+
local function canAddDraftEntry(viewerKey)
local status = getDraftResolution(viewerKey)
- return status == "resolved"
+ if status ~= "resolved" then
+ return false
+ end
+
+ local isDuplicate = getDraftDuplicateInfo(viewerKey)
+ return not isDuplicate
end
- local function addDraftEntry(viewerKey)
+ local function addDraftEntry(viewerKey, row)
local draftState = draftStates[viewerKey]
local id = ExtraIconsOptions._parseSingleId(draftState.idText)
if not id or not canAddDraftEntry(viewerKey) then
@@ -746,6 +1065,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
draftState.idText = ""
scheduleUpdate()
ExtraIconsOptions._refresh()
+ focusDraftEditBox(row)
end
local function restoreDefaultExtraIcons()
@@ -804,7 +1124,8 @@ function ExtraIconsOptions.RegisterSettings(SB)
viewerCanvas._viewerDraftRows[viewerKey] = row
row._viewerKey = viewerKey
- setButtonTooltip(row._typeBtn, L["ENTRY_TYPE"])
+ setButtonTooltip(row._typeBtn, L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"])
+ setButtonTooltip(row._addBtn, L["ADD_ENTRY"])
row._typeBtn:SetScript("OnClick", function()
if isDisabled() then
@@ -826,11 +1147,43 @@ function ExtraIconsOptions.RegisterSettings(SB)
end)
row._editBox:SetScript("OnEnterPressed", function()
- addDraftEntry(viewerKey)
+ addDraftEntry(viewerKey, row)
+ end)
+
+ row._editBox:SetScript("OnTabPressed", function(self)
+ if isDisabled() then
+ return
+ end
+
+ local draftState = draftStates[viewerKey]
+ draftState.kind = draftState.kind == "spell" and "item" or "spell"
+ ExtraIconsOptions._refresh()
+ focusDraftEditBox(row)
+ end)
+
+ row._editBox:SetScript("OnEditFocusGained", function()
+ row._editBoxHasFocus = true
+ refreshDraftPlaceholder(row, viewerKey)
+ if row._editBox.HighlightText and (row._editBox:GetText() or "") ~= "" then
+ row._editBox:HighlightText()
+ end
+ end)
+
+ row._editBox:SetScript("OnEditFocusLost", function()
+ row._editBoxHasFocus = false
+ refreshDraftPlaceholder(row, viewerKey)
+ end)
+
+ row._editBox:SetScript("OnEscapePressed", function(self)
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ row._editBoxHasFocus = false
+ refreshDraftPlaceholder(row, viewerKey)
end)
row._addBtn:SetScript("OnClick", function()
- addDraftEntry(viewerKey)
+ addDraftEntry(viewerKey, row)
end)
return row
@@ -840,6 +1193,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
local draftState = draftStates[viewerKey]
local controlsDisabled = isDisabled()
local status, name, icon = getDraftResolution(viewerKey)
+ local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
row._typeBtn:SetText(draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"])
if row._typeBtn.SetEnabled then
@@ -854,6 +1208,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
if row._editBox.SetEnabled then
row._editBox:SetEnabled(not controlsDisabled)
end
+ refreshDraftPlaceholder(row, viewerKey)
if icon then
row._previewIcon:SetTexture(icon)
@@ -863,22 +1218,24 @@ function ExtraIconsOptions.RegisterSettings(SB)
row._previewIcon:Hide()
end
- if status == "resolved" then
+ if status == "resolved" and isDuplicate then
+ row._previewLabel:SetText(
+ L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey)))
+ row._previewLabel:Show()
+ elseif status == "resolved" then
row._previewLabel:SetText(name or "")
row._previewLabel:Show()
- row._addBtn:Show()
elseif status == "pending" then
row._previewLabel:SetText(DRAFT_PENDING_TEXT)
row._previewLabel:Show()
- row._addBtn:Hide()
else
row._previewLabel:SetText("")
row._previewLabel:Hide()
- row._addBtn:Hide()
end
+ row._addBtn:Show()
if row._addBtn.SetEnabled then
- row._addBtn:SetEnabled(not controlsDisabled and status == "resolved")
+ row._addBtn:SetEnabled(not controlsDisabled and status == "resolved" and not isDuplicate)
end
end
@@ -886,24 +1243,36 @@ function ExtraIconsOptions.RegisterSettings(SB)
local controlsDisabled = isDisabled()
local displayEntry = rowData.displayEntry
local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
- local entries = getViewers()[rowData.viewerKey] or {}
+ local duplicateViewerKey = rowData.index ~= nil
+ and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index)
+ or nil
+ local hasMoveDuplicate = duplicateViewerKey == otherViewer
+ local positionLocked = rowData.isBuiltin and rowData.isDisabled
+ local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not positionLocked
+ local canMove = not controlsDisabled and rowData.index ~= nil and not positionLocked and not hasMoveDuplicate
row._label:SetText(ExtraIconsOptions._getEntryName(displayEntry))
row._icon:SetTexture(ExtraIconsOptions._getEntryIcon(displayEntry) or 134400)
setRowVisualState(row, rowData.isDisabled)
- row._upBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil and rowData.index > 1)
- row._downBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil and rowData.index < #entries)
- row._moveBtn:SetEnabled(not controlsDisabled and rowData.index ~= nil)
+ row._upBtn:SetEnabled(canReorder and rowData.activeIndex > 1)
+ row._downBtn:SetEnabled(canReorder and rowData.activeIndex < rowData.activeCount)
+ row._moveBtn:SetEnabled(canMove)
row._deleteBtn:SetEnabled(not controlsDisabled)
row._moveBtn:SetText(rowData.viewerKey == "utility" and ">" or "<")
setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
- setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(otherViewer))
+ if hasMoveDuplicate then
+ setButtonTooltip(row._moveBtn, L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer)))
+ elseif positionLocked then
+ setButtonTooltip(row._moveBtn, L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ else
+ setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(otherViewer))
+ end
row._upBtn:SetScript("OnClick", function()
- if rowData.index == nil then
+ if rowData.index == nil or not canReorder then
return
end
@@ -913,7 +1282,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
end)
row._downBtn:SetScript("OnClick", function()
- if rowData.index == nil then
+ if rowData.index == nil or not canReorder then
return
end
@@ -923,7 +1292,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
end)
row._moveBtn:SetScript("OnClick", function()
- if rowData.index == nil then
+ if rowData.index == nil or not canMove then
return
end
@@ -934,7 +1303,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
if rowData.isBuiltin then
row._deleteBtn:SetText(rowData.isDisabled and "+" or "x")
- setButtonTooltip(row._deleteBtn, rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["DISABLE_TOOLTIP"])
+ setButtonTooltip(row._deleteBtn, rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"])
row._deleteBtn:SetScript("OnClick", function()
ExtraIconsOptions._toggleBuiltinRow(
getProfile(),
@@ -970,7 +1339,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
clearRowMouseover(row)
setRowMouseover(row, function(self)
- setItemStackTooltip(self, displayEntry)
+ showRowTooltip(self, rowData)
end)
end
@@ -983,6 +1352,12 @@ function ExtraIconsOptions.RegisterSettings(SB)
local viewers = getViewers()
local y = 0
+ viewerCanvas._legendLabel:ClearAllPoints()
+ viewerCanvas._legendLabel:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
+ viewerCanvas._legendLabel:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
+ viewerCanvas._legendLabel:Show()
+ y = y - SPECIAL_ROWS_LEGEND_HEIGHT
+
for _, viewerKey in ipairs(VIEWER_ORDER) do
local headerRow = viewerCanvas._viewerHeaders[viewerKey]
headerRow:ClearAllPoints()
@@ -1004,7 +1379,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
viewerCanvas._viewerEmptyLabels[viewerKey]:SetPoint(
"TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
viewerCanvas._viewerEmptyLabels[viewerKey]:Show()
- y = y - ROW_HEIGHT
+ y = y - ROW_HEIGHT - VIEWER_ROW_SPACING
else
viewerCanvas._viewerEmptyLabels[viewerKey]:Hide()
end
@@ -1021,7 +1396,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
row:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
row:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
row:Show()
- y = y - ROW_HEIGHT
+ y = y - ROW_HEIGHT - VIEWER_ROW_SPACING
end
local draftRow = ensureDraftRow(viewerKey)
@@ -1065,7 +1440,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
viewers = {
type = "canvas",
canvas = viewerCanvas,
- height = 400,
+ height = VIEWER_CANVAS_HEIGHT,
disabled = isDisabled,
order = 10,
},
diff --git a/UI/ResourceBarOptions.lua b/UI/ResourceBarOptions.lua
index f1a6a89f..36618dcb 100644
--- a/UI/ResourceBarOptions.lua
+++ b/UI/ResourceBarOptions.lua
@@ -119,4 +119,4 @@ function ResourceBarOptions.RegisterSettings(SB)
})
end
- ns.SettingsBuilder.RegisterSection(ns, "ResourceBar", ResourceBarOptions)
+ns.SettingsBuilder.RegisterSection(ns, "ResourceBar", ResourceBarOptions)
From b00dbf758b3f50d1efbcb93d9a3fb7ae7b0fa99d Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 10 Apr 2026 22:52:50 +1000
Subject: [PATCH 07/53] Refactor to create new DSL widgets.
---
ARCHITECTURE.md | 6 +
ECM.lua | 9 +-
.../LibSettingsBuilder/LibSettingsBuilder.lua | 1350 ++++++++++++++++-
Libs/LibSettingsBuilder/README.md | 14 +-
.../Tests/LibSettingsBuilder_spec.lua | 241 ++-
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 49 +-
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 4 +-
.../docs/MIGRATION_GUIDE.md | 7 +-
Libs/LibSettingsBuilder/docs/QUICK_START.md | 6 +-
.../docs/TROUBLESHOOTING.md | 4 +-
Locales/en.lua | 3 -
Tests/TestHelpers.lua | 87 +-
Tests/UI/About_spec.lua | 22 +-
Tests/UI/BuffBarsOptions_spec.lua | 214 +--
Tests/UI/ExtraIconsOptions_spec.lua | 757 +++------
Tests/UI/PowerBarTickMarksOptions_spec.lua | 388 ++---
Tests/UI/ProfileOptions_spec.lua | 13 +-
UI/BuffBarsOptions.lua | 393 ++---
UI/ExtraIconsOptions.lua | 781 +++-------
UI/Options.lua | 52 +-
UI/PowerBarTickMarksOptions.lua | 509 +++----
UI/ProfileOptions.lua | 82 +-
22 files changed, 2761 insertions(+), 2230 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a821671b..61e8f871 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -193,6 +193,12 @@ LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced
Setting changes flow through LibSettingsBuilder's `onChange` → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`.
+Options pages now use one LibSettingsBuilder DSL for both simple and complex screens:
+
+- standard persisted controls (`toggle`, `range`, `select`, `color`, `input`) bind through path mode or handler mode,
+- layout rows (`header`, `subheader`, `info`, `button`) handle structure and copy,
+- dynamic editors use `collection` rows with library-owned list/section rendering plus `SB.RefreshCategory(...)` for async or transient state such as profile pickers and item-preview updates.
+
### Watchdog Ticker
A 0.5s `C_Timer.NewTicker` handles deferred Blizzard frame hooking (stops retrying once setup completes), enforces hidden/alpha state against Blizzard re-shows, and syncs module alpha.
diff --git a/ECM.lua b/ECM.lua
index 150e298b..17ae28d6 100644
--- a/ECM.lua
+++ b/ECM.lua
@@ -368,7 +368,7 @@ function mod:OnInitialize()
ns.Migration.FlushLog()
- -- Register bundled font with LibSharedMedia if present.
+ -- Register bundled fonts with LibSharedMedia
if LSM then
pcall(
LSM.Register,
@@ -377,6 +377,13 @@ function mod:OnInitialize()
"Expressway",
"Interface\\AddOns\\EnhancedCooldownManager\\media\\Fonts\\Expressway.ttf"
)
+ pcall(
+ LSM.Register,
+ LSM,
+ "font",
+ "Cabin",
+ "Interface\\AddOns\\EnhancedCooldownManager\\media\\Fonts\\Cabin.ttf"
+ )
end
local chatHandler = function(input) mod:ChatCommand(input) end
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index f36d46ab..3b85c1ea 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -6,7 +6,7 @@
-- World of Warcraft Settings API. Provides proxy controls, composite groups
-- and utility helpers.
-local MAJOR, MINOR = "LibSettingsBuilder-1.0", 1
+local MAJOR, MINOR = "LibSettingsBuilder-1.0", 2
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
return
@@ -18,6 +18,8 @@ lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
+local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
+
lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
@@ -114,6 +116,10 @@ local function setInitializerExtent(initializer, extent)
end
local function getInitializerData(initializer)
+ if initializer and initializer._lsbData then
+ return initializer._lsbData
+ end
+
if initializer and initializer.GetData then
return initializer:GetData()
end
@@ -213,6 +219,54 @@ local function resetPlainListElementFrame(frame)
resetListElement(frame)
end
+local function showFrame(frame)
+ if frame and frame.Show then
+ frame:Show()
+ end
+end
+
+local function setGameTooltipText(text, wrap)
+ GameTooltip:SetText(text, TOOLTIP_TITLE_COLOR, 1, wrap == true)
+end
+
+local function setSimpleTooltip(owner, text)
+ if not owner or not owner.SetScript then
+ return
+ end
+
+ owner:SetScript("OnEnter", nil)
+ owner:SetScript("OnLeave", nil)
+
+ if not text or text == "" then
+ return
+ end
+
+ owner:SetScript("OnEnter", function(self)
+ if not GameTooltip then
+ return
+ end
+
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ setGameTooltipText(text, true)
+ GameTooltip:Show()
+ end)
+ owner:SetScript("OnLeave", function()
+ if GameTooltip_Hide then
+ GameTooltip_Hide()
+ end
+ end)
+end
+
+local function evaluateStaticOrFunction(value, ...)
+ if type(value) == "function" then
+ return value(...)
+ end
+ return value
+end
+
local function createSubheaderTitle(parent, text)
local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
title:SetPoint("TOPLEFT", parent, "TOPLEFT", 35, -8)
@@ -274,17 +328,167 @@ local function ensureInfoRowWidgets(frame)
return title, value
end
+local function ensureHeaderRowWidgets(frame)
+ if frame._lsbHeaderTitle then
+ return frame
+ end
+
+ frame._lsbHeaderTitle = createHeaderTitle(frame)
+ frame._lsbHeaderActionButtons = frame._lsbHeaderActionButtons or {}
+
+ return frame
+end
+
+local function getSettingsListHeader()
+ local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ return settingsList and settingsList.Header or nil
+end
+
+local function hideHeaderActionButtons(frame)
+ for _, button in ipairs(frame._lsbHeaderActionButtons or {}) do
+ button:SetScript("OnClick", nil)
+ button:SetScript("OnEnter", nil)
+ button:SetScript("OnLeave", nil)
+ button:Hide()
+ end
+end
+
+local function applyHeaderActionButtons(frame, actions, actionParent, rightAnchor)
+ ensureHeaderRowWidgets(frame)
+ local buttons = frame._lsbHeaderActionButtons
+ local anchor = nil
+ local visibleCount = 0
+
+ actionParent = actionParent or frame
+ hideHeaderActionButtons(frame)
+
+ for _, action in ipairs(actions or {}) do
+ if not evaluateStaticOrFunction(action.hidden, action, frame) then
+ visibleCount = visibleCount + 1
+
+ local button = buttons[visibleCount]
+ if button and button._lsbActionParent ~= actionParent then
+ button:Hide()
+ button = nil
+ end
+ if not button then
+ button = CreateFrame("Button", nil, actionParent, "UIPanelButtonTemplate")
+ button._lsbActionParent = actionParent
+ buttons[visibleCount] = button
+ end
+
+ button:ClearAllPoints()
+ if anchor then
+ button:SetPoint("RIGHT", anchor, "LEFT", -8, 0)
+ elseif rightAnchor then
+ button:SetPoint("RIGHT", rightAnchor, "LEFT", -8, 0)
+ else
+ button:SetPoint("RIGHT", actionParent, "RIGHT", -20, 0)
+ end
+ button:SetSize(action.width or 100, action.height or 22)
+ button:SetText(action.text or action.name or "")
+ if button.SetEnabled then
+ local enabled = evaluateStaticOrFunction(action.enabled, action, frame)
+ if enabled == nil then
+ enabled = true
+ end
+ button:SetEnabled(enabled)
+ end
+ setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, frame))
+ button:SetScript("OnClick", function()
+ if action.onClick then
+ action.onClick(action, frame)
+ end
+ end)
+ button:Show()
+ anchor = button
+ end
+ end
+end
+
local function applySubheaderFrame(frame, data)
local title = ensureSubheaderTitle(frame)
title:SetText(data.name)
title:Show()
end
+local function applyHeaderFrame(frame, data)
+ ensureHeaderRowWidgets(frame)
+ local settingsHeader = data.attachToCategoryHeader and getSettingsListHeader() or nil
+ local actionParent = settingsHeader or frame
+ local rightAnchor = settingsHeader and settingsHeader.DefaultsButton or nil
+
+ if frame._lsbHeaderTitle then
+ frame._lsbHeaderTitle:ClearAllPoints()
+ frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
+ frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ frame._lsbHeaderTitle:SetText(data.name or "")
+ end
+
+ applyHeaderActionButtons(frame, data.actions, actionParent, rightAnchor)
+
+ if frame._lsbHeaderTitle then
+ if data.hideTitle then
+ frame._lsbHeaderTitle:Hide()
+ return
+ end
+
+ local titleRight = -20
+ local buttons = frame._lsbHeaderActionButtons or {}
+ for i = 1, #buttons do
+ local button = buttons[i]
+ if button and button.IsShown and button:IsShown() then
+ frame._lsbHeaderTitle:ClearAllPoints()
+ frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
+ frame._lsbHeaderTitle:SetPoint("RIGHT", button, "LEFT", -12, 0)
+ titleRight = nil
+ break
+ end
+ end
+ if titleRight then
+ frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", titleRight, 0)
+ end
+ frame._lsbHeaderTitle:Show()
+ end
+end
+
local function applyInfoRowFrame(frame, data)
local title, value = ensureInfoRowWidgets(frame)
- title:SetText(data.name)
- value:SetText(data.value)
- title:Show()
+ local name = evaluateStaticOrFunction(data.name, frame, data)
+ local resolvedValue = evaluateStaticOrFunction(data.value, frame, data)
+ local isWide = data.wide == true or name == nil or name == ""
+ local isMultiline = data.multiline == true
+
+ title:ClearAllPoints()
+ value:ClearAllPoints()
+
+ if isWide then
+ title:SetText("")
+ title:Hide()
+ value:SetPoint("TOPLEFT", frame, "TOPLEFT", 37, isMultiline and -4 or 0)
+ value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ else
+ title:SetText(name or "")
+ title:SetPoint("LEFT", 37, 0)
+ title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
+ title:Show()
+ value:SetPoint("LEFT", frame, "CENTER", -80, 0)
+ value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ end
+
+ if value.SetWordWrap then
+ value:SetWordWrap(isMultiline)
+ end
+ if value.SetJustifyV then
+ value:SetJustifyV(isMultiline and "TOP" or "MIDDLE")
+ end
+ if value.SetJustifyH then
+ value:SetJustifyH("LEFT")
+ end
+ value:SetText(resolvedValue or "")
+ if not isWide then
+ title:Show()
+ end
value:Show()
end
@@ -584,6 +788,7 @@ local function createCustomListRowInitializer(template, data, extent, initFrame)
resetPlainListElementFrame(frame)
initFrame(frame, self.data, self)
+ self._lsbActiveFrame = frame
if not frame._lsbHasCustomEvaluateState then
frame.EvaluateState = function(control)
@@ -615,6 +820,12 @@ local function createCustomListRowInitializer(template, data, extent, initFrame)
frame._lsbCanvas:Hide()
frame._lsbCanvas = nil
end
+ if self._lsbActiveFrame == frame then
+ self._lsbActiveFrame = nil
+ end
+ if self._lsbResetFrame then
+ self._lsbResetFrame(frame, self)
+ end
resetPlainListElementFrame(frame)
frame.data = nil
@@ -703,7 +914,8 @@ local function configureScrollDropdownFrame(frame, initializer)
copyMixin(frame, ScrollDropdownMethods)
frame.initializer = initializer
- frame.lsbData = initializer:GetData() or {}
+ frame.lsbData = getInitializerData(initializer) or {}
+ initializer._lsbActiveFrame = frame
frame:InitDropdown()
end
@@ -725,6 +937,929 @@ if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownC
lib._scrollDropdownHookInstalled = true
end
+local function roundSliderValue(value, step, minValue, maxValue)
+ local actualStep = step or 1
+ local baseValue = minValue or 0
+ local rounded = math.floor(((value - baseValue) / actualStep) + 0.5) * actualStep + baseValue
+ if minValue then
+ rounded = math.max(minValue, rounded)
+ end
+ if maxValue then
+ rounded = math.min(maxValue, rounded)
+ end
+ return rounded
+end
+
+local function getSliderStepCount(minValue, maxValue, step)
+ return math.max(1, math.floor(((maxValue - minValue) / (step or 1)) + 0.5))
+end
+
+local function createInlineSliderFormatters()
+ if not MinimalSliderWithSteppersMixin or not MinimalSliderWithSteppersMixin.Label then
+ return nil
+ end
+
+ return {
+ [MinimalSliderWithSteppersMixin.Label.Right] = function()
+ return ""
+ end,
+ }
+end
+
+local function attachInlineSliderEditor(slider, textLabel, editBoxWidth)
+ if slider._lsbValueButton then
+ return
+ end
+
+ local function hideEditBox()
+ if slider._lsbEditBox and slider._lsbEditBox.ClearFocus then
+ slider._lsbEditBox:ClearFocus()
+ end
+ if slider._lsbEditBox then
+ slider._lsbEditBox:Hide()
+ end
+ if textLabel and textLabel.Show then
+ textLabel:Show()
+ end
+ end
+
+ local function applyEditBoxValue()
+ local editBox = slider._lsbEditBox
+ local enteredValue = editBox and tonumber(editBox:GetText())
+ if enteredValue then
+ local minValue = slider._lsbMinValue or 0
+ local maxValue = slider._lsbMaxValue
+ if slider._lsbRangeResolver then
+ local nextMin, nextMax, nextStep = slider._lsbRangeResolver(enteredValue)
+ if nextMin ~= nil then
+ minValue = nextMin
+ end
+ if nextMax ~= nil then
+ maxValue = nextMax
+ end
+ if nextStep ~= nil then
+ slider._lsbStep = nextStep
+ end
+ if maxValue ~= nil then
+ slider._lsbMaxValue = maxValue
+ end
+ slider._lsbMinValue = minValue
+ end
+
+ slider:SetValue(roundSliderValue(enteredValue, slider._lsbStep, minValue, maxValue))
+ end
+ hideEditBox()
+ end
+
+ local valueButton = CreateFrame("Button", nil, slider)
+ valueButton:RegisterForClicks("LeftButtonDown")
+ valueButton:SetAllPoints(textLabel)
+ slider._lsbValueButton = valueButton
+
+ local editBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate")
+ editBox:SetAutoFocus(false)
+ editBox:SetNumeric(false)
+ editBox:SetSize(editBoxWidth or 50, 20)
+ editBox:SetPoint("CENTER", textLabel, "CENTER")
+ editBox:SetJustifyH("CENTER")
+ editBox:Hide()
+ slider._lsbEditBox = editBox
+
+ editBox:SetScript("OnEnterPressed", applyEditBoxValue)
+ editBox:SetScript("OnEscapePressed", hideEditBox)
+ editBox:SetScript("OnEditFocusLost", hideEditBox)
+
+ valueButton:SetScript("OnClick", function()
+ editBox:SetText(textLabel and textLabel.GetText and textLabel:GetText() or "")
+ if textLabel and textLabel.Hide then
+ textLabel:Hide()
+ end
+ editBox:Show()
+ editBox:SetFocus()
+ editBox:HighlightText()
+ end)
+end
+
+local function configureInlineSlider(slider, textLabel, field, onValueChanged)
+ local minValue = field.min or 0
+ local maxValue = field.max or 1
+ local step = field.step or 1
+
+ slider._lsbMinValue = minValue
+ slider._lsbMaxValue = maxValue
+ slider._lsbStep = step
+ slider._lsbRangeResolver = field.getRange
+
+ if slider.MinText then
+ slider.MinText:Hide()
+ end
+ if slider.MaxText then
+ slider.MaxText:Hide()
+ end
+ if slider.RightText then
+ slider.RightText:Hide()
+ end
+
+ if slider.Init then
+ slider:Init(minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters())
+ if slider.Slider and slider.Slider.SetValueStep then
+ slider.Slider:SetValueStep(step)
+ end
+ else
+ slider:SetMinMaxValues(minValue, maxValue)
+ slider:SetValueStep(step)
+ slider:SetObeyStepOnDrag(true)
+ end
+
+ attachInlineSliderEditor(slider, textLabel, field.editWidth or 50)
+
+ if not slider._lsbValueChangedBound then
+ local function handleValueChanged(_, value)
+ local rounded = roundSliderValue(value, slider._lsbStep, slider._lsbMinValue, slider._lsbMaxValue)
+ if textLabel and textLabel.SetText then
+ textLabel:SetText(tostring(rounded))
+ end
+ if onValueChanged then
+ onValueChanged(rounded)
+ end
+ end
+
+ if slider.RegisterCallback and MinimalSliderWithSteppersMixin and MinimalSliderWithSteppersMixin.Event then
+ slider:RegisterCallback(MinimalSliderWithSteppersMixin.Event.OnValueChanged, handleValueChanged, slider)
+ else
+ slider:HookScript("OnValueChanged", handleValueChanged)
+ end
+ slider._lsbValueChangedBound = true
+ end
+end
+
+local function applyCollectionRowStyle(row, item)
+ local alpha = item and item.alpha or 1
+
+ if row._label and row._label.SetFontObject and item and item.labelFontObject then
+ row._label:SetFontObject(item.labelFontObject)
+ end
+ if row._label and row._label.SetTextColor and item and item.labelColor then
+ row._label:SetTextColor(
+ item.labelColor[1] or 1,
+ item.labelColor[2] or 1,
+ item.labelColor[3] or 1,
+ item.labelColor[4] or 1
+ )
+ end
+ if row._label and row._label.SetAlpha then
+ row._label:SetAlpha(alpha)
+ end
+ if row._icon and row._icon.SetAlpha then
+ row._icon:SetAlpha(alpha)
+ end
+ if row._icon and row._icon.SetDesaturated then
+ row._icon:SetDesaturated(item and item.iconDesaturated == true or false)
+ end
+ if row._icon and row._icon.SetVertexColor then
+ local color = item and item.iconVertexColor
+ if color then
+ row._icon:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
+ else
+ row._icon:SetVertexColor(1, 1, 1, 1)
+ end
+ end
+end
+
+local function bindCollectionRowTooltip(row, item)
+ if not row or not row.SetScript then
+ return
+ end
+
+ row:SetScript("OnEnter", nil)
+ row:SetScript("OnLeave", nil)
+
+ if not item then
+ return
+ end
+
+ row:SetScript("OnEnter", function(self)
+ if self._highlight and self._highlight.Show then
+ self._highlight:Show()
+ end
+ if item.onEnter then
+ item.onEnter(self, item)
+ elseif item.tooltip then
+ if GameTooltip then
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ setGameTooltipText(item.tooltip, true)
+ GameTooltip:Show()
+ end
+ end
+ end)
+ row:SetScript("OnLeave", function(self)
+ if self._highlight and self._highlight.Hide then
+ self._highlight:Hide()
+ end
+ if item.onLeave then
+ item.onLeave(self, item)
+ elseif GameTooltip_Hide then
+ GameTooltip_Hide()
+ end
+ end)
+end
+
+local function ensureHighlight(row)
+ if row._highlight then
+ return row._highlight
+ end
+
+ local highlight = row:CreateTexture(nil, "BACKGROUND")
+ highlight:SetAllPoints()
+ highlight:SetColorTexture(1, 1, 1, 0.08)
+ highlight:Hide()
+ row._highlight = highlight
+ return highlight
+end
+
+local function ensureSwatchCollectionRow(row)
+ if row._lsbSwatchRow then
+ return
+ end
+
+ row._lsbSwatchRow = true
+ row:SetHeight(26)
+ ensureHighlight(row)
+
+ row._icon = row:CreateTexture(nil, "ARTWORK")
+ row._icon:SetPoint("LEFT", 0, 0)
+ row._icon:SetSize(16, 16)
+ row._icon:Hide()
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
+ row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
+ row._label:SetJustifyH("LEFT")
+ row._label:SetWordWrap(false)
+
+ row._swatch = lib.CreateColorSwatch(row)
+ row._swatch:SetPoint("RIGHT", row, "RIGHT", -18, 0)
+end
+
+local function refreshSwatchCollectionRow(row, item)
+ ensureSwatchCollectionRow(row)
+
+ if item.icon then
+ row._icon:SetTexture(item.icon)
+ row._icon:Show()
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
+ else
+ row._icon:SetTexture(nil)
+ row._icon:Hide()
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
+ row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
+ end
+
+ row._label:SetText(item.label or "")
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local color = item.color or {}
+ local colorValue = color.value or color
+ row._swatch:SetColorRGB(colorValue.r or 1, colorValue.g or 1, colorValue.b or 1)
+ setSimpleTooltip(row._swatch, item.swatchTooltip or color.tooltip)
+ row._swatch:SetScript("OnClick", function()
+ local onClick = color.onClick or item.onColorClick
+ if onClick then
+ onClick(item, row)
+ end
+ end)
+ if row._swatch.SetEnabled then
+ row._swatch:SetEnabled(
+ evaluateStaticOrFunction(item.enabled, item, row) ~= false
+ and evaluateStaticOrFunction(color.enabled, item, row) ~= false
+ )
+ end
+end
+
+local function ensureEditorCollectionRow(row)
+ if row._lsbEditorRow then
+ return
+ end
+
+ row._lsbEditorRow = true
+ row:SetHeight(34)
+ ensureHighlight(row)
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row, "LEFT", 10, 0)
+ row._label:SetWidth(70)
+ row._label:SetJustifyH("LEFT")
+
+ row._fieldWidgets = {}
+ row._swatch = lib.CreateColorSwatch(row)
+ row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._removeButton:SetSize(70, 22)
+end
+
+local function ensureEditorFieldWidgets(row, index)
+ local widgets = row._fieldWidgets[index]
+ if widgets then
+ return widgets
+ end
+
+ local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
+ local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ valueText:SetJustifyH("LEFT")
+
+ widgets = {
+ slider = slider,
+ valueText = valueText,
+ }
+ row._fieldWidgets[index] = widgets
+ return widgets
+end
+
+local function refreshEditorCollectionRow(row, item)
+ ensureEditorCollectionRow(row)
+
+ row._label:SetText(item.label or "")
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local labelAnchor = row._label
+ local previousControl = labelAnchor
+ local previousValueText = nil
+ local fields = item.fields or {}
+
+ for i = 1, #fields do
+ local field = fields[i]
+ local widgets = ensureEditorFieldWidgets(row, i)
+ local slider = widgets.slider
+ local valueText = widgets.valueText
+ local minValue, maxValue, step = field.min or 0, field.max or 1, field.step or 1
+
+ if field.getRange then
+ local nextMin, nextMax, nextStep = field.getRange(item, field.value)
+ if nextMin ~= nil then
+ minValue = nextMin
+ end
+ if nextMax ~= nil then
+ maxValue = nextMax
+ end
+ if nextStep ~= nil then
+ step = nextStep
+ end
+ end
+
+ field.min = minValue
+ field.max = maxValue
+ field.step = step
+
+ slider:ClearAllPoints()
+ if previousValueText then
+ slider:SetPoint("LEFT", previousValueText, "RIGHT", field.gap or 12, 0)
+ else
+ slider:SetPoint("LEFT", row._label, "RIGHT", 8, 0)
+ end
+ slider:SetWidth(field.sliderWidth or 120)
+
+ valueText:ClearAllPoints()
+ valueText:SetPoint("LEFT", slider, "RIGHT", 6, 0)
+ valueText:SetWidth(field.valueWidth or 40)
+
+ configureInlineSlider(slider, valueText, field, function(rounded)
+ if row._lsbRefreshing then
+ return
+ end
+ if field.onValueChanged then
+ field.onValueChanged(rounded, item, row)
+ end
+ end)
+
+ previousControl = slider
+ previousValueText = valueText
+ end
+
+ local color = item.color or {}
+ row._swatch:ClearAllPoints()
+ if previousValueText then
+ row._swatch:SetPoint("LEFT", previousValueText, "RIGHT", 10, 0)
+ else
+ row._swatch:SetPoint("LEFT", row._label, "RIGHT", 10, 0)
+ end
+
+ row._removeButton:ClearAllPoints()
+ row._removeButton:SetPoint("LEFT", row._swatch, "RIGHT", 8, 0)
+ row._removeButton:SetSize((item.remove and item.remove.width) or 70, 22)
+ row._removeButton:SetText((item.remove and item.remove.text) or REMOVE or "Remove")
+ row._removeButton:SetScript("OnClick", function()
+ if item.remove and item.remove.onClick then
+ item.remove.onClick(item, row)
+ end
+ end)
+ if row._removeButton.SetEnabled then
+ row._removeButton:SetEnabled(item.remove == nil or item.remove.enabled ~= false)
+ end
+ setSimpleTooltip(row._removeButton, item.remove and item.remove.tooltip)
+
+ row._lsbRefreshing = true
+ for i = 1, #fields do
+ local field = fields[i]
+ local widgets = row._fieldWidgets[i]
+ widgets.slider._lsbMinValue = field.min or 0
+ widgets.slider._lsbMaxValue = field.max or 1
+ widgets.slider._lsbStep = field.step or 1
+ if widgets.slider.SetValue then
+ widgets.slider:SetValue(field.value or 0)
+ end
+ widgets.valueText:SetText(tostring(field.value or 0))
+ end
+ row._lsbRefreshing = nil
+
+ row._swatch:SetColorRGB((color.value and color.value.r) or 1, (color.value and color.value.g) or 1, (color.value and color.value.b) or 1)
+ row._swatch:SetScript("OnClick", function()
+ if color.onClick then
+ color.onClick(item, row)
+ end
+ end)
+ setSimpleTooltip(row._swatch, color.tooltip)
+ if row._swatch.SetEnabled then
+ row._swatch:SetEnabled(color.enabled ~= false)
+ end
+end
+
+local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
+
+local function ensureActionsCollectionRow(row)
+ if row._lsbActionsRow then
+ return
+ end
+
+ row._lsbActionsRow = true
+ row:SetHeight(26)
+ ensureHighlight(row)
+
+ row._icon = row:CreateTexture(nil, "ARTWORK")
+ row._icon:SetPoint("LEFT", 0, 0)
+ row._icon:SetSize(20, 20)
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetJustifyH("LEFT")
+ row._label:SetWordWrap(false)
+
+ row._buttons = {}
+ for _, key in ipairs(ACTION_BUTTON_ORDER) do
+ local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._buttons[key] = button
+ end
+end
+
+local function refreshActionsCollectionRow(row, item)
+ ensureActionsCollectionRow(row)
+
+ row._label:SetText(item.label or "")
+ row._icon:SetTexture(item.icon or 134400)
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local anchor = nil
+ for _, key in ipairs(ACTION_BUTTON_ORDER) do
+ local button = row._buttons[key]
+ local action = item.actions and item.actions[key] or nil
+
+ button:ClearAllPoints()
+ button:SetScript("OnClick", nil)
+ button:SetScript("OnEnter", nil)
+ button:SetScript("OnLeave", nil)
+
+ if action and not evaluateStaticOrFunction(action.hidden, action, row, item) then
+ if not anchor then
+ button:SetPoint("RIGHT", row, "RIGHT", 0, 0)
+ else
+ button:SetPoint("RIGHT", anchor, "LEFT", -2, 0)
+ end
+ button:SetSize(action.width or 26, action.height or 22)
+ button:SetText(action.text or "")
+ if button.SetEnabled then
+ local enabled = evaluateStaticOrFunction(action.enabled, action, row, item)
+ if enabled == nil then
+ enabled = true
+ end
+ button:SetEnabled(enabled)
+ end
+ setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, row, item))
+ button:SetScript("OnClick", function()
+ if action.onClick then
+ action.onClick(item, row, action)
+ end
+ end)
+ button:Show()
+ anchor = button
+ else
+ button:Hide()
+ end
+ end
+
+ if anchor then
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetPoint("RIGHT", anchor, "LEFT", -6, 0)
+ else
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
+ end
+end
+
+local function ensureModeInputRow(row)
+ if row._lsbModeInputRow then
+ return
+ end
+
+ row._lsbModeInputRow = true
+ row:SetHeight(28)
+
+ row._modeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._modeButton:SetPoint("LEFT", row, "LEFT", 0, 0)
+ row._modeButton:SetSize(58, 22)
+
+ row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
+ row._editBox:SetPoint("LEFT", row._modeButton, "RIGHT", 6, 0)
+ row._editBox:SetSize(120, 20)
+ row._editBox:SetAutoFocus(false)
+ if row._editBox.SetNumeric then
+ row._editBox:SetNumeric(true)
+ end
+ if row._editBox.SetMaxLetters then
+ row._editBox:SetMaxLetters(10)
+ end
+ if row._editBox.SetTextInsets then
+ row._editBox:SetTextInsets(6, 6, 0, 0)
+ end
+
+ row._placeholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ row._placeholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0)
+ row._placeholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0)
+ row._placeholder:SetJustifyH("LEFT")
+ row._placeholder:SetWordWrap(false)
+
+ row._previewIcon = row:CreateTexture(nil, "ARTWORK")
+ row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
+ row._previewIcon:SetSize(16, 16)
+ row._previewIcon:Hide()
+
+ row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0)
+ row._previewLabel:SetJustifyH("LEFT")
+ row._previewLabel:SetWordWrap(false)
+ row._previewLabel:Hide()
+
+ row._submitButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._submitButton:SetPoint("RIGHT", row, "RIGHT", 0, 0)
+ row._submitButton:SetSize(44, 22)
+ row._submitButton:SetText(ADD or "Add")
+
+ row._previewLabel:SetPoint("RIGHT", row._submitButton, "LEFT", -6, 0)
+
+ row._editBox:SetScript("OnEditFocusGained", function()
+ row._lsbHasFocus = true
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+ row._editBox:SetScript("OnEditFocusLost", function()
+ row._lsbHasFocus = false
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+ row._editBox:SetScript("OnTextChanged", function(self)
+ if row._lsbSyncingText then
+ return
+ end
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onTextChanged then
+ trailer.onTextChanged(self:GetText() or "", trailer, row, row._lsbSectionData)
+ end
+ end)
+ row._editBox:SetScript("OnEnterPressed", function()
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onSubmit then
+ local keepFocus = trailer.onSubmit(trailer, row, row._lsbSectionData)
+ if keepFocus then
+ row._editBox:SetFocus()
+ row._editBox:HighlightText()
+ end
+ end
+ end)
+ row._editBox:SetScript("OnTabPressed", function()
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onTabPressed then
+ local keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData)
+ if keepFocus then
+ row._editBox:SetFocus()
+ row._editBox:HighlightText()
+ end
+ end
+ end)
+ row._editBox:SetScript("OnEscapePressed", function(self)
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onEscapePressed then
+ trailer.onEscapePressed(trailer, row, row._lsbSectionData)
+ end
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ row._lsbHasFocus = false
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+end
+
+local function refreshModeInputRow(row, trailer, sectionData)
+ ensureModeInputRow(row)
+
+ row._lsbTrailerData = trailer
+ row._lsbSectionData = sectionData
+
+ row._lsbTrailerRefresh = function(activeRow)
+ local currentTrailer = activeRow._lsbTrailerData or {}
+ local text = currentTrailer.inputText or ""
+
+ activeRow._modeButton:SetText(currentTrailer.modeText or "")
+ setSimpleTooltip(activeRow._modeButton, currentTrailer.modeTooltip)
+ activeRow._modeButton:SetScript("OnClick", function()
+ if currentTrailer.onToggleMode then
+ currentTrailer.onToggleMode(currentTrailer, activeRow, activeRow._lsbSectionData)
+ end
+ end)
+ if activeRow._modeButton.SetEnabled then
+ activeRow._modeButton:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.modeEnabled ~= false)
+ end
+
+ if activeRow._editBox.GetText and activeRow._editBox:GetText() ~= text then
+ activeRow._lsbSyncingText = true
+ activeRow._editBox:SetText(text)
+ activeRow._lsbSyncingText = nil
+ end
+ if activeRow._editBox.SetEnabled then
+ activeRow._editBox:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.inputEnabled ~= false)
+ end
+
+ activeRow._placeholder:SetText(currentTrailer.placeholder or "")
+ if activeRow._lsbHasFocus or text ~= "" then
+ activeRow._placeholder:Hide()
+ else
+ activeRow._placeholder:Show()
+ end
+
+ if currentTrailer.previewIcon then
+ activeRow._previewIcon:SetTexture(currentTrailer.previewIcon)
+ activeRow._previewIcon:Show()
+ else
+ activeRow._previewIcon:SetTexture(nil)
+ activeRow._previewIcon:Hide()
+ end
+
+ if currentTrailer.previewText and currentTrailer.previewText ~= "" then
+ activeRow._previewLabel:SetText(currentTrailer.previewText)
+ activeRow._previewLabel:Show()
+ else
+ activeRow._previewLabel:SetText("")
+ activeRow._previewLabel:Hide()
+ end
+
+ activeRow._submitButton:SetText(currentTrailer.submitText or ADD or "Add")
+ setSimpleTooltip(activeRow._submitButton, currentTrailer.submitTooltip)
+ activeRow._submitButton:SetScript("OnClick", function()
+ if currentTrailer.onSubmit then
+ local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData)
+ if keepFocus then
+ activeRow._editBox:SetFocus()
+ activeRow._editBox:HighlightText()
+ end
+ end
+ end)
+ if activeRow._submitButton.SetEnabled then
+ activeRow._submitButton:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.submitEnabled ~= false)
+ end
+ end
+
+ row._lsbTrailerRefresh(row)
+end
+
+local function ensureCollectionContent(frame)
+ if frame._lsbCollectionContent then
+ showFrame(frame._lsbCollectionContent)
+ return frame._lsbCollectionContent
+ end
+
+ local content = CreateFrame("Frame", nil, frame)
+ content:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
+ content:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
+ content:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
+ content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
+ frame._lsbCollectionContent = content
+ return content
+end
+
+local function ensureFlatCollectionWidgets(frame, data)
+ if frame._lsbCollectionScrollBox then
+ showFrame(frame._lsbCollectionScrollBox)
+ showFrame(frame._lsbCollectionScrollBar)
+ return
+ end
+
+ local insetLeft = data.insetLeft or 37
+ local insetRight = data.insetRight or 20
+ local insetTop = data.insetTop or 0
+ local insetBottom = data.insetBottom or 10
+
+ local scrollBox = CreateFrame("Frame", nil, frame, "WowScrollBoxList")
+ scrollBox:SetPoint("TOPLEFT", frame, "TOPLEFT", insetLeft, insetTop)
+ scrollBox:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, insetBottom)
+
+ local scrollBar = CreateFrame("EventFrame", nil, frame, "MinimalScrollBar")
+ scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
+ scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
+
+ local view = CreateScrollBoxListLinearView()
+ view:SetElementExtent(data.rowHeight or 26)
+ view:SetElementInitializer("Frame", function(rowFrame, rowData)
+ local preset = rowData.preset or data.preset
+ if preset == "swatch" then
+ refreshSwatchCollectionRow(rowFrame, rowData.item)
+ elseif preset == "editor" then
+ refreshEditorCollectionRow(rowFrame, rowData.item)
+ end
+ end)
+
+ local dataProvider = CreateDataProvider()
+ ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)
+ scrollBox:SetDataProvider(dataProvider)
+
+ frame._lsbCollectionScrollBox = scrollBox
+ frame._lsbCollectionScrollBar = scrollBar
+ frame._lsbCollectionView = view
+ frame._lsbCollectionDataProvider = dataProvider
+end
+
+local function refreshFlatCollection(frame, data)
+ ensureFlatCollectionWidgets(frame, data)
+
+ local scrollBox = frame._lsbCollectionScrollBox
+ local dataProvider = frame._lsbCollectionDataProvider
+ local items = data.items and data.items(frame) or {}
+
+ if dataProvider and dataProvider.Flush then
+ dataProvider:Flush()
+ end
+
+ for _, item in ipairs(items or {}) do
+ if dataProvider and dataProvider.Insert then
+ dataProvider:Insert({
+ preset = data.preset,
+ item = item,
+ })
+ end
+ end
+
+ if scrollBox and scrollBox.SetDataProvider then
+ scrollBox:SetDataProvider(dataProvider)
+ end
+end
+
+local function ensureSectionHeaderRow(content, headers, sectionKey, title)
+ local row = headers[sectionKey]
+ if row then
+ return row
+ end
+
+ row = CreateFrame("Frame", nil, content)
+ row:SetHeight(28)
+ row._title = createSubheaderTitle(row, title)
+ headers[sectionKey] = row
+ return row
+end
+
+local function ensureSectionEmptyLabel(content, labels, sectionKey)
+ local label = labels[sectionKey]
+ if label then
+ return label
+ end
+
+ label = content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ label:SetJustifyH("LEFT")
+ labels[sectionKey] = label
+ return label
+end
+
+local function refreshSectionedCollection(frame, data)
+ local content = ensureCollectionContent(frame)
+ local sections = data.sections and data.sections(frame) or {}
+ local headers = frame._lsbSectionHeaders or {}
+ local rowPools = frame._lsbSectionRowPools or {}
+ local emptyLabels = frame._lsbSectionEmptyLabels or {}
+ local trailerRows = frame._lsbSectionTrailerRows or {}
+ local y = 0
+ local insetLeft = data.insetLeft or 37
+ local insetRight = data.insetRight or 20
+ local rowSpacing = data.rowSpacing or 4
+ local sectionSpacing = data.sectionSpacing or 12
+
+ frame._lsbSectionHeaders = headers
+ frame._lsbSectionRowPools = rowPools
+ frame._lsbSectionEmptyLabels = emptyLabels
+ frame._lsbSectionTrailerRows = trailerRows
+
+ for sectionKey, pool in pairs(rowPools) do
+ for _, row in ipairs(pool) do
+ row:Hide()
+ end
+ end
+ for _, row in pairs(headers) do
+ row:Hide()
+ end
+ for _, label in pairs(emptyLabels) do
+ label:Hide()
+ end
+ for _, trailer in pairs(trailerRows) do
+ trailer:Hide()
+ end
+
+ for _, section in ipairs(sections) do
+ local sectionKey = section.key or section.name or tostring(_)
+ local header = ensureSectionHeaderRow(content, headers, sectionKey, section.title or section.name or "")
+ header._title:SetText(section.title or section.name or "")
+ header:ClearAllPoints()
+ header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
+ header:SetPoint("RIGHT", content, "RIGHT", 0, 0)
+ header:Show()
+ y = y - (section.headerHeight or 28)
+
+ local items = section.items or {}
+ local pool = rowPools[sectionKey] or {}
+ rowPools[sectionKey] = pool
+
+ if #items == 0 and section.emptyText then
+ local label = ensureSectionEmptyLabel(content, emptyLabels, sectionKey)
+ label:SetText(section.emptyText)
+ label:ClearAllPoints()
+ label:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ label:Show()
+ y = y - ((section.emptyHeight or 26) + rowSpacing)
+ end
+
+ for index, item in ipairs(items) do
+ local row = pool[index]
+ if not row then
+ row = CreateFrame("Frame", nil, content)
+ pool[index] = row
+ end
+
+ refreshActionsCollectionRow(row, item)
+ row:ClearAllPoints()
+ row:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ row:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
+ row:Show()
+ y = y - ((section.rowHeight or 26) + rowSpacing)
+ end
+
+ if section.trailer and section.trailer.preset == "modeInput" then
+ local trailerRow = trailerRows[sectionKey]
+ if not trailerRow then
+ trailerRow = CreateFrame("Frame", nil, content)
+ trailerRows[sectionKey] = trailerRow
+ end
+
+ refreshModeInputRow(trailerRow, section.trailer, section)
+ trailerRow:ClearAllPoints()
+ trailerRow:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ trailerRow:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
+ trailerRow:Show()
+ y = y - (section.trailerHeight or 28)
+ end
+
+ y = y - (section.spacingAfter or sectionSpacing)
+ end
+end
+
+local function applyCollectionFrame(frame, data, initializer)
+ frame.OnDefault = data.onDefault
+ frame._lsbCollectionData = data
+ frame._lsbCollectionInitializer = initializer
+
+ if data.sections then
+ refreshSectionedCollection(frame, data)
+ else
+ refreshFlatCollection(frame, data)
+ end
+end
+
--------------------------------------------------------------------------------
-- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
-- Replicates Blizzard's Settings panel positioning so canvas pages are
@@ -1160,6 +2295,7 @@ function lib:New(config)
SB._layouts = {}
SB._firstHeaderAdded = {}
SB._reactiveControls = {}
+ SB._categoryRefreshables = {}
SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
@@ -1192,7 +2328,28 @@ function lib:New(config)
return spec.category or SB._currentSubcategory or SB._rootCategory
end
+ local function registerCategoryRefreshable(category, initializer)
+ if not category or not initializer then
+ return
+ end
+
+ local refreshables = SB._categoryRefreshables[category]
+ if not refreshables then
+ refreshables = {}
+ SB._categoryRefreshables[category] = refreshables
+ end
+
+ for _, existing in ipairs(refreshables) do
+ if existing == initializer then
+ return
+ end
+ end
+
+ refreshables[#refreshables + 1] = initializer
+ end
+
local reevaluateReactiveControls
+ local setCanvasInteractive
local function postSet(spec, value, setting)
if spec.onSet then
@@ -1384,7 +2541,7 @@ function lib:New(config)
end
end
- local function setCanvasInteractive(frame, enabled)
+ setCanvasInteractive = function(frame, enabled)
if frame.SetEnabled then
frame:SetEnabled(enabled)
end
@@ -1523,8 +2680,9 @@ function lib:New(config)
return category
end
- function SB.CreateSubcategory(name)
- local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(SB._rootCategory, name)
+ function SB.CreateSubcategory(name, parentCategory)
+ local parent = parentCategory or SB._rootCategory
+ local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
SB._subcategories[name] = subcategory
SB._subcategoryNames[subcategory] = name
SB._layouts[subcategory] = layout
@@ -1536,6 +2694,7 @@ function lib:New(config)
local parent = parentCategory or SB._rootCategory
local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
SB._subcategories[name] = subcategory
+ SB._subcategoryNames[subcategory] = name
SB._layouts[subcategory] = layout
return subcategory
end
@@ -1652,36 +2811,62 @@ function lib:New(config)
or Settings.VarType.String
local setting = makeProxySetting(spec, varType, "", binding)
+ local function optionsGenerator()
+ local container = Settings.CreateControlTextContainer()
+ local values = type(spec.values) == "function" and spec.values() or spec.values
+ if values then
+ for _, entry in ipairs(getOrderedValueEntries(values)) do
+ container:Add(entry.value, entry.label)
+ end
+ end
+ return container:GetData()
+ end
+ setting._optionsGen = optionsGenerator
+ local initializer
if spec.scrollHeight then
- local initializer = Settings.CreateElementInitializer(lib.SCROLL_DROPDOWN_TEMPLATE, {
+ initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
+ initializer._lsbData = {
_lsbKind = "scrollDropdown",
setting = setting,
values = spec.values,
scrollHeight = spec.scrollHeight,
name = spec.name,
tooltip = spec.tooltip,
- })
+ }
if initializer.SetSetting then
initializer:SetSetting(setting)
end
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, spec)
- return initializer, setting
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.RefreshDropdownText then
+ frame:RefreshDropdownText()
+ end
+ end
+ registerCategoryRefreshable(cat, initializer)
+ else
+ initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
end
- local function optionsGenerator()
- local container = Settings.CreateControlTextContainer()
- local values = type(spec.values) == "function" and spec.values() or spec.values
- if values then
- for _, entry in ipairs(getOrderedValueEntries(values)) do
- container:Add(entry.value, entry.label)
+ if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
+ initializer:SetSetting(setting)
+ end
+ if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.InitDropdown then
+ frame:InitDropdown(initializer)
+ elseif frame and frame.SetValue and setting.GetValue then
+ frame:SetValue(setting:GetValue())
end
end
- return container:GetData()
+ registerCategoryRefreshable(cat, initializer)
+ end
+
+ if not initializer.GetSetting then
+ initializer.GetSetting = function()
+ return setting
+ end
end
- local initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
applyModifiers(initializer, spec)
return initializer, setting
@@ -1829,6 +3014,41 @@ function lib:New(config)
return fn(spec)
end
+ function SB.Collection(spec)
+ assert(spec.height, "Collection: spec.height is required")
+
+ local cat = resolveCategory(spec)
+ local data = {}
+ for key, value in pairs(spec) do
+ data[key] = value
+ end
+
+ local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
+
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ local activeFrame = controlInitializer._lsbActiveFrame
+ if activeFrame then
+ if activeFrame.SetAlpha then
+ activeFrame:SetAlpha(enabled and 1 or 0.5)
+ end
+ setCanvasInteractive(activeFrame, enabled)
+ end
+ end
+
+ initializer._lsbRefreshFrame = function(frame)
+ applyCollectionFrame(frame, data, initializer)
+ initializer.SetEnabled(initializer, initializer._lsbEnabled ~= false)
+ end
+
+ Settings.RegisterInitializer(cat, initializer)
+ registerCategoryRefreshable(cat, initializer)
+ applyModifiers(initializer, spec)
+
+ return initializer
+ end
+
----------------------------------------------------------------------------
-- Composite builders
----------------------------------------------------------------------------
@@ -2038,16 +3258,45 @@ function lib:New(config)
local cat = resolveCategory(spec)
local text = spec.name
+ local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName)
+ local matchesCategoryTitle = catName and text == catName
+ local isFirstHeader = not SB._firstHeaderAdded[cat]
+ local attachToCategoryHeader = isFirstHeader and matchesCategoryTitle and spec.actions
- if not SB._firstHeaderAdded[cat] then
+ if isFirstHeader then
SB._firstHeaderAdded[cat] = true
- local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName)
- if catName and text == catName then
+ if matchesCategoryTitle and not spec.actions then
return nil
end
end
local layout = SB._layouts[cat]
+ if spec.actions then
+ local height = spec.height
+ or (attachToCategoryHeader
+ and 1
+ or (
+ (spec.hideTitle == true or (isFirstHeader and matchesCategoryTitle))
+ and 34
+ or 50
+ ))
+ local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ _lsbKind = "header",
+ name = text,
+ actions = spec.actions,
+ hideTitle = spec.hideTitle == true or (isFirstHeader and matchesCategoryTitle),
+ attachToCategoryHeader = attachToCategoryHeader == true,
+ }, height, applyHeaderFrame)
+ initializer._lsbRefreshFrame = function(frame)
+ applyHeaderFrame(frame, initializer:GetData())
+ end
+ initializer._lsbResetFrame = hideHeaderActionButtons
+ layout:AddInitializer(initializer)
+ registerCategoryRefreshable(cat, initializer)
+ applyModifiers(initializer, spec)
+ return initializer
+ end
+
local initializer = CreateSettingsListSectionHeaderInitializer(text)
layout:AddInitializer(initializer)
applyModifiers(initializer, spec)
@@ -2073,8 +3322,16 @@ function lib:New(config)
_lsbKind = "infoRow",
name = spec.name,
value = spec.value,
- }, 26, applyInfoRowFrame)
+ wide = spec.wide,
+ multiline = spec.multiline,
+ }, spec.height or 26, applyInfoRowFrame)
layout:AddInitializer(initializer)
+ initializer._lsbRefreshFrame = function(frame)
+ applyInfoRowFrame(frame, initializer:GetData())
+ end
+ if type(spec.value) == "function" or type(spec.name) == "function" then
+ registerCategoryRefreshable(cat, initializer)
+ end
applyModifiers(initializer, spec)
return initializer
end
@@ -2137,6 +3394,45 @@ function lib:New(config)
return initializer
end
+ function SB.RefreshCategory(categoryOrName)
+ local category = categoryOrName
+ if type(categoryOrName) == "string" then
+ category = SB._subcategories[categoryOrName]
+ or (categoryOrName == SB._rootCategoryName and SB._rootCategory)
+ end
+ if not category then
+ return
+ end
+
+ local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil
+ local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category
+
+ local refreshables = SB._categoryRefreshables[category] or {}
+ for _, initializer in ipairs(refreshables) do
+ if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer)
+ end
+ end
+
+ if not isVisible then
+ return
+ end
+
+ local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ local scrollBox = settingsList and settingsList.ScrollBox
+ if scrollBox and scrollBox.ForEachFrame then
+ scrollBox:ForEachFrame(function(frame)
+ local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ if initializer and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(frame, initializer)
+ end
+ end)
+ end
+ end
+
----------------------------------------------------------------------------
-- Table-driven registration (AceConfig-inspired)
----------------------------------------------------------------------------
@@ -2175,7 +3471,7 @@ function lib:New(config)
if tbl.rootCategory then
SB._currentSubcategory = SB._rootCategory
else
- SB.CreateSubcategory(tbl.name)
+ SB.CreateSubcategory(tbl.name, tbl.parentCategory)
end
if tbl.onShow or tbl.onHide then
@@ -2276,6 +3572,8 @@ function lib:New(config)
init = SB.InfoRow(spec)
elseif entryType == "button" then
init = SB.Button(spec)
+ elseif entryType == "collection" then
+ init = SB.Collection(spec)
elseif entryType == "canvas" then
init = SB.EmbedCanvas(entry.canvas, entry.height, spec)
elseif entryType == "colorList" then
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index 4b23bb7f..a00b306b 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -7,10 +7,11 @@ It supports:
- path-based bindings for AceDB-style profile tables,
- handler-mode bindings for arbitrary storage,
- built-in text input rows with optional debounced preview resolution,
+- first-class dynamic collections for scrollable or sectioned list editors,
- composite builders for common settings groups,
- layout-only rows such as headers, subheaders, info rows, buttons, and embedded canvases,
- XML/template-backed custom controls when a built-in row is not enough,
-- canvas layout helpers for more complex pages,
+- category refresh hooks for out-of-band state changes,
- deterministic dropdown ordering,
- clickable slider value editing.
@@ -25,9 +26,10 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
| Existing AceDB profiles | `PathAdapter(...)` |
| Custom storage | handler mode with `get` / `set` / `key` |
| Text entry / numeric ID fields | `SB.Input(...)` or `type = "input"` |
+| Dynamic editors / ordered lists | `SB.Collection(...)` or `type = "collection"` |
| Reusable settings groups | border, font override, positioning composites |
| XML-backed bespoke widgets | `SB.Custom(...)` |
-| Custom settings pages | `CreateCanvasLayout(...)` |
+| Force visible rows to refresh | `SB.RefreshCategory(...)` |
## Quick start
@@ -92,6 +94,7 @@ The table API understands both AceConfig-style aliases and library-specific row
| `description` | Alias for a subheader row |
| `info` | Left-label / right-value informational row |
| `canvas` | Embedded frame row for canvas content |
+| `collection` | First-class dynamic list/section widget |
| `custom` | Proxy setting backed by a custom XML template |
| `colorList` | Expands `defs` into multiple color swatches |
| `toggleList` | Expands `defs` into multiple checkboxes |
@@ -142,9 +145,11 @@ spellId = {
The library has three main implementation paths:
- **Proxy controls** — `checkbox`, `slider`, `dropdown`, `color`, `input`, and `custom` all go through the same proxy-setting pipeline. That means path mode and handler mode work consistently across them.
-- **Layout rows** — `header`, `subheader`, `info`, `button`, and `canvas` are initializer/layout helpers rather than persisted settings.
+- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `collection` are initializer/layout helpers rather than persisted settings.
- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `toggleList` expand into multiple child controls.
+`header` supports optional `actions = { ... }` for right-aligned page or section buttons, and `collection` covers the common "custom canvas page" cases without making authors drop into a second authoring API. Use `canvas` and `custom` as escape hatches, not the default path.
+
`input` specifically is implemented as a built-in custom list row using `SettingsListElementTemplate`, with an `InputBoxTemplate` edit box anchored in the standard left-label / right-control layout. It does **not** need a separate XML template the way `custom` controls do.
Under the hood, an input row:
@@ -179,7 +184,8 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr
- Embed the library inside your addon's `Libs/` folder.
- Load `LibStub` before `LibSettingsBuilder`.
-- Canvas layout spacing can be tuned globally or per layout.
+- Prefer one `RegisterFromTable(...)` DSL for both simple rows and complex editors.
+- `SB.RefreshCategory(...)` is the intended way to refresh dynamic info rows, dropdown options, and collections after profile mutations, async item loads, or other out-of-band changes.
- Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks.
## License
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index c81e8b37..b8a0a1cb 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -187,6 +187,8 @@ describe("LibSettingsBuilder", function()
"SettingsDropdownControlMixin",
"SettingsSliderControlMixin",
"C_Timer",
+ "GameTooltip",
+ "GameTooltip_Hide",
"GameFontHighlight",
"GameFontHighlightSmall",
"GameFontNormal",
@@ -1334,26 +1336,18 @@ describe("LibSettingsBuilder", function()
assert.are.equal(0, #warnings)
end)
- -- Dropdown with scrollHeight
- it("Dropdown with scrollHeight uses scroll template", function()
- local capturedTemplate
- local settings = Settings
- local origCreateElementInitializer = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(template, data)
- capturedTemplate = template
- return origCreateElementInitializer(template, data)
- end)
-
- local _, setting = SB.Dropdown({
+ it("Dropdown with scrollHeight keeps Blizzard's native dropdown options callback", function()
+ local init, setting = SB.Dropdown({
path = "global.mode",
name = "Scrollable Mode",
values = { solid = "Solid", flat = "Flat" },
scrollHeight = 300,
})
- rawset(settings, "CreateElementInitializer", origCreateElementInitializer)
-
- assert.are.equal(SB.SCROLL_DROPDOWN_TEMPLATE, capturedTemplate)
+ assert.is_function(init._optionsGen)
+ assert.are.equal("scrollDropdown", init._lsbData._lsbKind)
+ assert.are.equal(300, init._lsbData.scrollHeight)
+ assert.are.equal(setting, init:GetSetting())
assert.are.equal("solid", setting:GetValue())
setting:SetValue("flat")
@@ -1433,18 +1427,16 @@ describe("LibSettingsBuilder", function()
SetValue = function() end,
}
local initializer = {
- GetData = function()
- return {
- _lsbKind = "scrollDropdown",
- setting = setting,
- values = {
- gamma = "Gamma",
- alpha = "Alpha",
- beta = "Beta",
- },
- scrollHeight = 240,
- }
- end,
+ _lsbData = {
+ _lsbKind = "scrollDropdown",
+ setting = setting,
+ values = {
+ gamma = "Gamma",
+ alpha = "Alpha",
+ beta = "Beta",
+ },
+ scrollHeight = 240,
+ },
GetSetting = function()
return setting
end,
@@ -2328,6 +2320,203 @@ describe("LibSettingsBuilder", function()
---------------------------------------------------------------------------
-- SB.Custom integration: template, setting, and InitFrame pipeline
---------------------------------------------------------------------------
+ describe("Dynamic layout rows", function()
+ it("Header accepts action buttons through spec tables", function()
+ local init = SB.Header({
+ name = "TestSection",
+ actions = {
+ { text = "Defaults", width = 100 },
+ },
+ })
+
+ assert.are.equal(SB.SUBHEADER_TEMPLATE, init._template)
+ assert.are.equal("TestSection", init.data.name)
+ assert.are.equal("Defaults", init.data.actions[1].text)
+ end)
+
+ it("Header action tooltips use the current GameTooltip SetText signature", function()
+ TestHelpers.SetupGameTooltipStub()
+
+ local init = SB.Header({
+ name = "TestSection",
+ actions = {
+ { text = "Add", tooltip = "Create entry" },
+ },
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.CreateFontString = function()
+ return createScriptableFrame()
+ end
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ assert.has_no.errors(function()
+ init:InitFrame(frame)
+ end)
+
+ local button = assert(frame._lsbHeaderActionButtons[1])
+ assert.has_no.errors(function()
+ button:GetScript("OnEnter")(button)
+ end)
+
+ assert.are.equal("Create entry", _G.GameTooltip._title)
+ assert.are.same({ r = 1, g = 1, b = 1, a = 1 }, _G.GameTooltip._titleColor)
+ assert.are.equal(1, _G.GameTooltip._titleAlpha)
+ assert.is_true(_G.GameTooltip._titleWrap)
+ end)
+
+ it("Header hides the duplicated title when actions are attached to the page title row", function()
+ local init = SB.Header({
+ name = "TestSection",
+ category = SB._currentSubcategory,
+ actions = {
+ { text = "Defaults", width = 100 },
+ },
+ })
+
+ assert.is_table(init)
+ assert.is_true(init.data.hideTitle)
+ assert.is_true(init.data.attachToCategoryHeader)
+ assert.are.equal(1, init:GetExtent())
+ end)
+
+ it("InfoRow with function-backed value registers as refreshable", function()
+ local category = SB._currentSubcategory
+ SB.InfoRow({
+ name = "Dynamic",
+ value = function()
+ return "value"
+ end,
+ })
+
+ assert.is_table(SB._categoryRefreshables[category])
+ assert.are.equal(1, #SB._categoryRefreshables[category])
+ end)
+
+ it("RegisterFromTable dispatches collection rows through SB.Collection", function()
+ local called
+ local originalCollection = SB.Collection
+ SB.Collection = function(spec)
+ called = spec
+ return { _type = "collection" }
+ end
+
+ SB.RegisterFromTable({
+ name = "Collection Page",
+ args = {
+ items = {
+ type = "collection",
+ height = 200,
+ preset = "swatch",
+ items = function()
+ return {}
+ end,
+ order = 1,
+ },
+ },
+ })
+
+ SB.Collection = originalCollection
+
+ assert.is_table(called)
+ assert.are.equal(200, called.height)
+ assert.are.equal("swatch", called.preset)
+ end)
+
+ it("RefreshCategory reevaluates visible frames and dynamic refreshables", function()
+ local frames = createSettingsPanelMock()
+ local category = SB._currentSubcategory
+ local refreshed = 0
+ local frame = createScriptableFrame()
+ frame.EvaluateState = function(self)
+ self._evaluated = true
+ end
+
+ frames[1] = frame
+ SettingsPanel:SetCurrentCategory(category)
+
+ SB._categoryRefreshables[category] = {
+ {
+ _lsbActiveFrame = frame,
+ _lsbRefreshFrame = function(activeFrame)
+ refreshed = refreshed + 1
+ activeFrame._refreshed = true
+ end,
+ },
+ }
+
+ SB.RefreshCategory(category)
+
+ assert.are.equal(1, refreshed)
+ assert.is_true(frame._evaluated)
+ assert.is_true(frame._refreshed)
+ end)
+
+ it("Collection shows cached scroll widgets when a settings row is reused", function()
+ local originalCreateFrame = _G.CreateFrame
+ local originalCreateDataProvider = _G.CreateDataProvider
+ local originalCreateView = _G.CreateScrollBoxListLinearView
+ local originalScrollUtil = _G.ScrollUtil
+
+ _G.CreateFrame = function(_, _, _, template)
+ local frame = createScriptableFrame()
+ frame._template = template
+ frame.SetDataProvider = function(self, provider)
+ self._dataProvider = provider
+ end
+ return frame
+ end
+ _G.CreateDataProvider = function()
+ return {
+ Flush = function() end,
+ Insert = function() end,
+ }
+ end
+ _G.CreateScrollBoxListLinearView = function()
+ return {
+ SetElementExtent = function() end,
+ SetElementInitializer = function() end,
+ }
+ end
+ _G.ScrollUtil = {
+ InitScrollBoxListWithScrollBar = function() end,
+ }
+
+ local init = SB.Collection({
+ height = 80,
+ preset = "swatch",
+ items = function()
+ return {}
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+ local scrollBox = assert(frame._lsbCollectionScrollBox)
+ local scrollBar = assert(frame._lsbCollectionScrollBar)
+ scrollBox:Hide()
+ scrollBar:Hide()
+
+ init:InitFrame(frame)
+
+ assert.is_true(scrollBox:IsShown())
+ assert.is_true(scrollBar:IsShown())
+
+ _G.CreateFrame = originalCreateFrame
+ _G.CreateDataProvider = originalCreateDataProvider
+ _G.CreateScrollBoxListLinearView = originalCreateView
+ _G.ScrollUtil = originalScrollUtil
+ end)
+ end)
+
describe("Custom control integration", function()
it("passes the actual template name to CreateElementInitializer", function()
local capturedTemplate
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index 3c155978..7dcd67db 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -48,12 +48,12 @@ The built-in path helpers support numeric segments like `colors.0`.
## Category helpers
- `SB.CreateRootCategory(name)`
-- `SB.CreateSubcategory(name)`
+- `SB.CreateSubcategory(name[, parentCategory])`
- `SB.CreateCanvasSubcategory(frame, name[, parentCategory])`
-- `SB.CreateCanvasLayout(name[, parentCategory])`
- `SB.RegisterCategories()`
- `SB.GetRootCategoryID()`
- `SB.GetSubcategoryID(name)`
+- `SB.RefreshCategory(categoryOrName)`
## Controls
@@ -145,6 +145,30 @@ Notes:
Dispatches to the correct control factory using `spec.type`.
+### `SB.Collection(spec)`
+
+Creates a first-class dynamic collection row backed by the normal settings list.
+
+Required fields:
+
+- `height`
+
+Flat-list fields:
+
+- `preset = "swatch"` or `preset = "editor"`
+- `items(frame)` → item list
+
+Sectioned-list fields:
+
+- `sections(frame)` → section list
+
+Supported collection row presets:
+
+- `swatch` — label/icon plus color swatch rows
+- `editor` — label plus one or more slider fields, optional swatch, and remove button
+- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`)
+- section trailers support `preset = "modeInput"` for toggle + input + preview + submit rows
+
## Composite builders
- `SB.HeightOverrideSlider(sectionPath[, spec])`
@@ -163,6 +187,9 @@ Dispatches to the correct control factory using `spec.type`.
- `SB.RegisterSection(nsTable, key, section)`
`SB.Button` supports `confirm = true` or a custom confirm string. Confirm dialogs are registered per button to avoid cross-button collisions.
+`SB.Header` also accepts spec tables with `actions = { ... }` for right-aligned action buttons. When the first action header matches the current category title, LibSettingsBuilder treats it as a page action bar and suppresses the duplicate in-list title text.
+`SB.InfoRow` accepts function-backed `value` for dynamic text.
+`SB.RefreshCategory(...)` re-evaluates registered dynamic rows for a visible category.
## Table-driven registration
@@ -181,6 +208,7 @@ Supported standard types:
- `subheader` / `description`
- `info`
- `canvas`
+- `collection`
Supported composite types:
@@ -199,22 +227,11 @@ The library has three main families of row builders:
- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `toggleList`).
`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template.
+`collection` is the preferred way to build dynamic editors without dropping into a second "canvas" authoring API. `canvas` / `EmbedCanvas` remain available as escape hatches for truly bespoke frames.
-## Canvas layout helpers
-
-`CreateCanvasLayout` returns a layout object with these methods:
-
-- `AddHeader(text)`
-- `AddSpacer(height)`
-- `AddDescription(text[, fontObject])`
-- `AddColorSwatch(label)`
-- `AddSlider(label, min, max[, step])`
-- `AddButton(label, buttonText)`
-- `AddScrollList(elementExtent)`
-
-### Canvas layout configuration
+## Legacy canvas helpers
-Library defaults live on `LSB.CanvasLayoutDefaults` and can be adjusted globally or per layout.
+Canvas-layout helpers still exist for backwards compatibility, but new pages should prefer `RegisterFromTable(...)` plus `collection`, dynamic `info` rows, handler-mode `dropdown`, and `header.actions`.
#### `SB.SetCanvasLayoutDefaults(overrides)`
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index 388dee3e..6dada88e 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -68,9 +68,9 @@ Only `SB.Custom(...)` requires you to supply your own template. In that case:
2. load that XML from your TOC before registering categories, and
3. pass the template name through `spec.template`.
-## Canvas layout compatibility
+## Legacy canvas layout compatibility
-Canvas layout spacing defaults are modeled after Blizzard's retail Settings panel measurements and can be adjusted when needed:
+Canvas layout spacing defaults are still available for older `CreateCanvasLayout(...)` pages, but new work should prefer `RegisterFromTable(...)` plus `collection` / dynamic rows.
- per-library via `SB.SetCanvasLayoutDefaults(overrides)`
- per-layout via `SB.ConfigureCanvasLayout(layout, overrides)`
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index 4057a0e2..3454b724 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -64,19 +64,20 @@ local SB = LSB:New({
- native Blizzard Settings integration,
- composite builders for common UI groups,
-- canvas layout helpers for complex pages,
+- first-class dynamic collection rows for complex editors,
- built-in text input rows with optional debounced previews,
+- explicit category refresh hooks for async/transient state,
- clickable slider value editing,
- deterministic dropdown ordering.
## Features you still build yourself
- specialized row templates,
-- bespoke canvas pages.
+- genuinely bespoke embedded frames.
If you only need text or numeric entry, use the built-in `input` type first. Reach for `SB.Custom(...)` only when you need a genuinely different widget.
-Use `SB.Custom(...)` or `CreateCanvasLayout(...)` when the standard controls stop fitting.
+If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "collection"` before reaching for `SB.Custom(...)` or `SB.EmbedCanvas(...)`.
## Migrating AceConfig input fields
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index 4996da4b..d85b39d4 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -14,6 +14,7 @@
- Use the **imperative API** if you want precise control over layout and call order.
- Use **handler mode** if your settings are not stored in a dot-path table.
- Use `input` rows when you need text or numeric entry without building a custom template.
+- Use `collection` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API.
## Table-driven setup
@@ -110,7 +111,7 @@ SB.Input({
SB.RegisterCategories()
```
-`RegisterFromTable(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `toggle`, `range`, `input`, `header`, `description`, `info`, `button`, and `canvas` entries on one page.
+`RegisterFromTable(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `toggle`, `range`, `input`, `header`, `description`, `info`, `button`, `collection`, and `canvas` entries on one page.
## Handler mode
@@ -159,4 +160,5 @@ SB.RegisterCategories()
- Keep `onChanged` fast; use it to refresh UI, not rebuild the world.
- Use composites for repeated patterns like borders, font overrides, and positioning.
- Prefer table-driven registration for large standard settings pages.
-- Reach for `SB.Custom(...)` only when built-ins like `input` stop fitting.
+- Use `SB.RefreshCategory(...)` for async or transient state that needs the visible page to redraw.
+- Reach for `SB.Custom(...)` or `SB.EmbedCanvas(...)` only when built-ins like `input` and `collection` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index 52a6ed1b..86f4bfc4 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -90,9 +90,9 @@ If debugging slider behavior:
- confirm no other addon is replacing the slider frame structure,
- test with other UI customizers disabled.
-## Canvas pages look slightly off after a WoW patch
+## Legacy canvas pages look slightly off after a WoW patch
-Canvas layout spacing is configurable.
+Canvas layout spacing is configurable for older `CreateCanvasLayout(...)` pages. New pages should prefer the standard settings DSL with `collection` rows instead of tuning canvas metrics by default.
Use:
diff --git a/Locales/en.lua b/Locales/en.lua
index 82979e54..96e3d7bf 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -189,8 +189,6 @@ L["HEIGHT_OVERRIDE"] = "Height Override"
L["HEIGHT_OVERRIDE_DESC"] = "Override the default bar height. Set to 0 to use the global default."
L["AURA_VERTICAL_SPACING"] = "Vertical Spacing"
L["AURA_VERTICAL_SPACING_DESC"] = "Vertical gap between aura bars. Set to 0 for no spacing."
-L["CONFIGURE_SPELL_COLORS"] = "Configure Spell Colors"
-L["OPEN"] = "Open"
L["SPELL_COLORS_SUBCAT"] = "Spell Colors"
L["SPELL_COLORS_DESC"] =
@@ -204,7 +202,6 @@ L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"] =
"Remove partial or stale entries that were discovered while spell information was secret. Reconcile should be attempted before this."
L["SPELL_COLORS_DONT_REMOVE"] = "Don't Remove"
L["SPELL_COLORS_REMOVED_STALE_ENTRY"] = "Removed stale spell color entry: %s"
-L["SPELL_COLORS_RESET_CONFIRM"] = "Are you sure you want to reset all spell colors for this spec?"
L["SPELL_COLORS_COMBAT_WARNING"] = "|cffFF0000These settings cannot be changed while in combat lockdown.|r"
L["SPELL_COLORS_SECRETS_WARNING"] =
"|cffFFDD3CSpell names are currently secret. Changes are blocked until you reload your UI out of combat.|r"
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 4a52ac91..98add12d 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -884,6 +884,59 @@ function TestHelpers.MakeOptionsProfile()
return deepClone(ns.defaults.profile), deepClone(ns.defaults.profile)
end
+function TestHelpers.SetupGameTooltipStub()
+ _G.GameTooltip = {
+ _title = nil,
+ _titleColor = nil,
+ _titleAlpha = nil,
+ _titleWrap = nil,
+ _lines = {},
+ _owner = nil,
+ _anchor = nil,
+ _shown = false,
+ SetOwner = function(self, owner, anchor)
+ self._owner = owner
+ self._anchor = anchor
+ end,
+ ClearLines = function(self)
+ self._title = nil
+ self._titleColor = nil
+ self._titleAlpha = nil
+ self._titleWrap = nil
+ self._lines = {}
+ end,
+ SetText = function(self, text, color, alpha, wrap, ...)
+ if type(color) == "number" or type(wrap) == "number" or select("#", ...) > 0 then
+ error("GameTooltip:SetText expects text, color, alpha, wrap", 2)
+ end
+ self._title = text
+ self._titleColor = color
+ self._titleAlpha = alpha
+ self._titleWrap = wrap
+ self._lines = {}
+ end,
+ AddLine = function(self, text, color, wrap, ...)
+ if type(color) == "number" or select("#", ...) > 0 then
+ error("GameTooltip:AddLine expects text, color, wrap", 2)
+ end
+ self._lines[#self._lines + 1] = text
+ end,
+ Show = function(self)
+ self._shown = true
+ end,
+ Hide = function(self)
+ self._shown = false
+ end,
+ }
+ _G.GameTooltip_Hide = function()
+ if _G.GameTooltip and _G.GameTooltip.Hide then
+ _G.GameTooltip:Hide()
+ end
+ end
+
+ return _G.GameTooltip
+end
+
--- Install common WoW globals for option tests.
function TestHelpers.SetupOptionsGlobals()
TestHelpers.SetupLibStub()
@@ -940,39 +993,7 @@ function TestHelpers.SetupOptionsGlobals()
_G.CreateAtlasMarkup = function(atlas)
return "|A" .. tostring(atlas) .. "|a"
end
- _G.GameTooltip = {
- _title = nil,
- _lines = {},
- _owner = nil,
- _anchor = nil,
- _shown = false,
- SetOwner = function(self, owner, anchor)
- self._owner = owner
- self._anchor = anchor
- end,
- ClearLines = function(self)
- self._title = nil
- self._lines = {}
- end,
- SetText = function(self, text)
- self._title = text
- self._lines = {}
- end,
- AddLine = function(self, text)
- self._lines[#self._lines + 1] = text
- end,
- Show = function(self)
- self._shown = true
- end,
- Hide = function(self)
- self._shown = false
- end,
- }
- _G.GameTooltip_Hide = function()
- if _G.GameTooltip and _G.GameTooltip.Hide then
- _G.GameTooltip:Hide()
- end
- end
+ TestHelpers.SetupGameTooltipStub()
_G.UnitName = function()
return "TestPlayer"
end
diff --git a/Tests/UI/About_spec.lua b/Tests/UI/About_spec.lua
index 681636d3..e1ff469f 100644
--- a/Tests/UI/About_spec.lua
+++ b/Tests/UI/About_spec.lua
@@ -95,12 +95,12 @@ describe("About section", function()
assert.is_not_nil(init, "expected Links subheader")
end)
- it("embeds a links canvas with two buttons", function()
- local init = findInitializer(rootLayout, function(i)
- return i.data and i.data.canvas and i.data.canvas._curseforge
- end)
- assert.is_not_nil(init, "expected links canvas")
- assert.is_not_nil(init.data.canvas._github)
+ it("adds plain button rows for the links", function()
+ local curseforge = TestHelpers.FindButtonInitializer(rootLayout._initializers, ns.L["CURSEFORGE"])
+ local github = TestHelpers.FindButtonInitializer(rootLayout._initializers, ns.L["GITHUB"])
+
+ assert.is_not_nil(curseforge, "expected CurseForge button row")
+ assert.is_not_nil(github, "expected GitHub button row")
end)
it("CurseForge button calls ShowCopyTextDialog with correct URL", function()
@@ -109,10 +109,7 @@ describe("About section", function()
captured = { url = url, title = title }
end
- local init = findInitializer(rootLayout, function(i)
- return i.data and i.data.canvas and i.data.canvas._curseforge
- end)
- init.data.canvas._curseforge:GetScript("OnClick")()
+ TestHelpers.FindButtonInitializer(rootLayout._initializers, ns.L["CURSEFORGE"])._onClick()
assert.is_not_nil(captured)
assert.are.equal("https://www.curseforge.com/wow/addons/enhanced-cooldown-manager", captured.url)
@@ -125,10 +122,7 @@ describe("About section", function()
captured = { url = url, title = title }
end
- local init = findInitializer(rootLayout, function(i)
- return i.data and i.data.canvas and i.data.canvas._github
- end)
- init.data.canvas._github:GetScript("OnClick")()
+ TestHelpers.FindButtonInitializer(rootLayout._initializers, ns.L["GITHUB"])._onClick()
assert.is_not_nil(captured)
assert.are.equal("https://github.com/argium/EnhancedCooldownManager", captured.url)
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 979a4711..3574ab0f 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -314,21 +314,56 @@ describe("BuffBarsOptions", function()
}))
end)
- it("ctrl-hovering a spell color row shows all keys for that row", function()
- local key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001)
+ local function registerSpellColorsSpec()
+ local spellColorsSpec
+ local buttonSpecs = {}
+ local refreshCalls = {}
+ local originalRegisterFromTable = SB.RegisterFromTable
+ local originalButton = SB.Button
+
+ SB.RegisterFromTable = function(tbl)
+ if tbl.name == ns.L["SPELL_COLORS_SUBCAT"] then
+ spellColorsSpec = tbl
+ end
+ return originalRegisterFromTable(tbl)
+ end
+ SB.Button = function(spec)
+ buttonSpecs[#buttonSpecs + 1] = spec
+ return originalButton(spec)
+ end
+ SB.RefreshCategory = function(category)
+ refreshCalls[#refreshCalls + 1] = category
+ end
BuffBarsOptions.RegisterSettings(SB)
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
- local control = CreateFrame("Frame")
+ SB.RegisterFromTable = originalRegisterFromTable
+ SB.Button = originalButton
+
+ return assert(spellColorsSpec), refreshCalls, buttonSpecs
+ end
+
+ it("does not add the old configure spell colors shortcut to aura bars", function()
+ local _, _, buttonSpecs = registerSpellColorsSpec()
+ for _, spec in ipairs(buttonSpecs) do
+ assert.are_not.equal("Configure Spell Colors", spec.name)
+ assert.are_not.equal("Open", spec.buttonText)
+ end
+ end)
+
+ it("ctrl-hovering a spell color collection row shows all keys for that row", function()
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
_G.IsControlKeyDown = function()
return true
end
- frame._spellColorListView._initFn(control, { key = key, textureFileID = 9001 })
- control.hooks.OnEnter[1](control)
+ local spellColorsSpec = registerSpellColorsSpec()
+ local item = spellColorsSpec.args.spellColorCollection.items()[2]
+
+ item.onEnter(CreateFrame("Frame"))
assert.are.equal("Spell color keys", _G.GameTooltip._title)
assert.are.same({
@@ -340,148 +375,147 @@ describe("BuffBarsOptions", function()
assert.is_true(_G.GameTooltip._shown)
end)
- it("spell colors canvas disables reconcile when every row already has all identifying keys", function()
- ns.SpellColors.GetAllColorEntries = function()
- return {
- { key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001) },
- }
+ it("puts the default spell color at the top of the collection", function()
+ local selectedColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+ local scheduledReason
+
+ ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
+ assert.is_false(hasOpacity)
+ onChange(selectedColor)
+ end
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
end
- BuffBarsOptions.RegisterSettings(SB)
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local defaultItem = spellColorsSpec.args.spellColorCollection.items()[1]
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
+ assert.are.equal(ns.L["DEFAULT_COLOR"], defaultItem.label)
- frame:RefreshSpellList()
+ defaultItem.color.onClick()
- assert.is_false(frame._reconcileButton:IsEnabled())
- assert.is_false(frame._removeStaleButton:IsEnabled())
+ assert.are.same(selectedColor, ns.SpellColors.GetDefaultColor())
+ assert.are.equal("OptionsChanged", scheduledReason)
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
- it("spell colors canvas enables reconcile and remove stale for incomplete rows", function()
- ns.SpellColors.GetAllColorEntries = function()
- return {
- { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
- }
+ it("collection defaults reset the default color and custom spell rows", function()
+ local scheduledReason
+
+ SpellColors.SetDefaultColor({ r = 0.7, g = 0.6, b = 0.5, a = 1 })
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
end
- BuffBarsOptions.RegisterSettings(SB)
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ spellColorsSpec.args.spellColorCollection.onDefault()
+
+ assert.are.same({}, SpellColors.GetAllColorEntries())
+ assert.are.same(ns.Constants.BUFFBARS_DEFAULT_COLOR, SpellColors.GetDefaultColor())
+ assert.are.equal("OptionsChanged", scheduledReason)
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
+ end)
+
+ it("header actions disable reconcile and remove stale when every row is complete", function()
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actions = spellColorsSpec.args.spellColorsHeader.actions
+
+ assert.is_false(actions[1].enabled())
+ assert.is_false(actions[2].enabled())
+ end)
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
+ it("header actions enable reconcile and remove stale for incomplete rows outside restricted areas", function()
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
- frame:RefreshSpellList()
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actions = spellColorsSpec.args.spellColorsHeader.actions
- assert.is_true(frame._reconcileButton:IsEnabled())
- assert.is_true(frame._removeStaleButton:IsEnabled())
+ assert.is_true(actions[1].enabled())
+ assert.is_true(actions[2].enabled())
end)
- it("spell colors canvas disables reconcile in restricted areas", function()
+ it("header actions disable reconcile and remove stale in restricted areas", function()
_G.IsInInstance = function()
return true, "party"
end
- ns.SpellColors.GetAllColorEntries = function()
- return {
- { key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
- }
- end
-
- BuffBarsOptions.RegisterSettings(SB)
-
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
+ SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
- frame:RefreshSpellList()
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actions = spellColorsSpec.args.spellColorsHeader.actions
- assert.is_false(frame._reconcileButton:IsEnabled())
- assert.is_false(frame._removeStaleButton:IsEnabled())
+ assert.is_false(actions[1].enabled())
+ assert.is_false(actions[2].enabled())
end)
- it("spell colors canvas reconcile button uses ConfirmReloadUI for unnamed rows", function()
+ it("reconcile action uses ConfirmReloadUI for incomplete rows", function()
local confirmText
- ns.SpellColors.GetAllColorEntries = function()
- return {
- { key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
- }
- end
+ SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
ns.Addon.ConfirmReloadUI = function(_, text)
confirmText = text
end
- BuffBarsOptions.RegisterSettings(SB)
-
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
- local onClick = assert(frame._reconcileButton:GetScript("OnClick"))
-
- frame:RefreshSpellList()
- assert.is_true(frame._reconcileButton:IsEnabled())
-
- onClick(frame._reconcileButton)
+ local spellColorsSpec = registerSpellColorsSpec()
+ spellColorsSpec.args.spellColorsHeader.actions[1].onClick()
assert.are.equal(ns.L["SPELL_COLORS_SECRET_NAMES_DESC"], confirmText)
end)
- it("spell colors canvas remove stale button shows the configured tooltip", function()
- BuffBarsOptions.RegisterSettings(SB)
-
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
-
- frame._removeStaleButton:GetScript("OnEnter")(frame._removeStaleButton)
-
- assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], _G.GameTooltip._title)
- assert.is_true(_G.GameTooltip._shown)
- end)
-
- it("spell colors canvas remove stale button confirms, removes stale entries, prints, and refreshes", function()
+ it("remove stale action exposes the configured tooltip and confirm flow", function()
local popupKey
local popupText
local acceptText
local cancelText
- local onAccept
+ local acceptFn
local scheduledReason
+ SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
scheduledReason = reason
end
- ns.Addon.ShowConfirmDialog = function(_, key, text, button1, button2, acceptFn)
+ ns.Addon.ShowConfirmDialog = function(_, key, text, button1, button2, onAccept)
popupKey = key
popupText = text
acceptText = button1
cancelText = button2
- onAccept = acceptFn
+ acceptFn = onAccept
end
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
- r = 0.2, g = 0.3, b = 0.4, a = 1,
- })
-
- BuffBarsOptions.RegisterSettings(SB)
-
- local spellColorsCategory = assert(SB.GetSubcategory(ns.L["SPELL_COLORS_SUBCAT"]))
- local frame = assert(spellColorsCategory._frame)
- local onClick = assert(frame._removeStaleButton:GetScript("OnClick"))
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local removeStaleAction = spellColorsSpec.args.spellColorsHeader.actions[2]
- frame:RefreshSpellList()
- assert.is_true(frame._removeStaleButton:IsEnabled())
+ assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], removeStaleAction.tooltip)
- onClick(frame._removeStaleButton)
+ removeStaleAction.onClick()
assert.are.equal("ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS", popupKey)
assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], popupText)
assert.are.equal(ns.L["REMOVE"], acceptText)
assert.are.equal(ns.L["SPELL_COLORS_DONT_REMOVE"], cancelText)
- assert.is_function(onAccept)
+ assert.is_function(acceptFn)
- onAccept()
+ acceptFn()
assert.are.same({}, ns.SpellColors.GetAllColorEntries())
assert.are.same({
ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("Immolation Aura"),
}, printedMessages)
assert.are.equal("OptionsChanged", scheduledReason)
- assert.is_false(frame._removeStaleButton:IsEnabled())
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
end)
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 3e8612f6..e850d19d 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -751,7 +751,28 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns, capturedTable
+ local profile, defaults, SB, ns, capturedTable, refreshCalls, scheduledReasons
+
+ local function buildSections()
+ return assert(capturedTable.args.viewers.sections())
+ end
+
+ local function getSection(sectionKey)
+ for _, section in ipairs(buildSections()) do
+ if section.key == sectionKey then
+ return section
+ end
+ end
+ end
+
+ local function findItem(sectionKey, predicate)
+ local section = assert(getSection(sectionKey))
+ for _, item in ipairs(section.items) do
+ if predicate(item) then
+ return item
+ end
+ end
+ end
setup(function()
originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
@@ -766,560 +787,240 @@ describe("ExtraIconsOptions settings page", function()
_G.UnitRace = function() return "Human", "Human", 1 end
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+ refreshCalls = {}
+ scheduledReasons = {}
+
+ profile.extraIcons = {
+ enabled = true,
+ viewers = {
+ utility = {},
+ main = {},
+ },
+ }
+ defaults.extraIcons = TestHelpers.deepClone(profile.extraIcons)
+
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReasons[#scheduledReasons + 1] = reason
+ end
local originalRegisterFromTable = SB.RegisterFromTable
SB.RegisterFromTable = function(tbl)
capturedTable = tbl
return originalRegisterFromTable(tbl)
end
+ SB.RefreshCategory = function(category)
+ refreshCalls[#refreshCalls + 1] = category
+ end
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ns.OptionsSections.ExtraIcons.RegisterSettings(SB)
end)
- local function refresh()
- ns.ExtraIconsOptions._refresh()
- end
-
- local function getVisibleRows(viewerKey)
- local rows = {}
- for _, row in ipairs(ns.ExtraIconsOptions._viewerCanvas._viewerRowPools[viewerKey]) do
- if row:IsShown() then
- rows[#rows + 1] = row
- end
- end
- return rows
- end
-
- local function findVisibleRowByText(viewerKey, text)
- for _, row in ipairs(getVisibleRows(viewerKey)) do
- if row._label:GetText() == text then
- return row
- end
- end
- return nil
- end
-
- local function getVisibleRowLabels(viewerKey)
- local labels = {}
- for _, row in ipairs(getVisibleRows(viewerKey)) do
- labels[#labels + 1] = row._label:GetText()
- end
- return labels
- end
-
- local function getDraftRow(viewerKey)
- refresh()
- return ns.ExtraIconsOptions._viewerCanvas._viewerDraftRows[viewerKey]
- end
-
- local function setDraftText(viewerKey, text)
- local row = getDraftRow(viewerKey)
- row._editBox:SetText(text)
- row._editBox:GetScript("OnTextChanged")(row._editBox)
- return row
- end
-
- describe("settings registration", function()
- it("creates a subcategory", function()
- assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
- end)
-
- it("exposes the viewer canvas and inline draft state for testing", function()
- local opts = ns.ExtraIconsOptions
- assert.is_not_nil(opts._viewerCanvas)
- assert.is_nil(opts._draftEntryCanvas)
- assert.is_table(opts._draftStates)
- assert.is_nil(opts._addFormCanvas)
- assert.is_nil(opts._presetsCanvas)
- end)
-
- it("viewer canvas exposes row pools, draft rows, and headers", function()
- local vc = ns.ExtraIconsOptions._viewerCanvas
- assert.is_table(vc._viewerRowPools)
- assert.is_table(vc._viewerDraftRows)
- assert.is_table(vc._viewerHeaders)
- assert.is_table(vc._viewerEmptyLabels)
- assert.is_not_nil(vc._legendLabel)
- assert.is_not_nil(vc._viewerHeaders.utility._title)
- assert.is_not_nil(vc._viewerHeaders.main._title)
- assert.are.equal(ns.L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"], vc._legendLabel:GetText())
- assert.are.equal(ns.L["UTILITY_VIEWER_ICONS"], vc._viewerHeaders.utility._title:GetText())
- assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], vc._viewerHeaders.main._title:GetText())
- end)
-
- it("registers only the enabled toggle and viewer canvas", function()
- assert.is_not_nil(capturedTable)
- assert.are.equal("toggle", capturedTable.args.enabled.type)
- assert.are.equal("canvas", capturedTable.args.viewers.type)
- assert.is_nil(capturedTable.args.addHeader)
- assert.is_nil(capturedTable.args.quickAdd_trinket1)
- assert.is_nil(capturedTable.args.quickAddRacial)
- end)
-
- it("uses inline draft rows to add custom entries per viewer", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function(spellId)
- return spellId == 12345 and "spell-tex" or nil
- end,
- }
-
- setDraftText("main", "12345")
-
- local draftRow = getDraftRow("main")
- assert.are.equal("Test Spell", draftRow._previewLabel:GetText())
- assert.is_true(draftRow._previewLabel:IsShown())
- assert.is_true(draftRow._addBtn:IsShown())
- assert.is_true(draftRow._addBtn:IsEnabled())
-
- draftRow._addBtn:GetScript("OnClick")()
-
- assert.are.equal("", ns.ExtraIconsOptions._draftStates.main.idText)
- assert.are.equal(1, #profile.extraIcons.viewers.main)
- assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
- assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
- end)
-
- it("shows pending draft resolution with ellipsis and a disabled add button", function()
- _G.C_Item = {
- DoesItemExistByID = function(itemId)
- return itemId == 777
- end,
- GetItemNameByID = function()
- return nil
- end,
- GetItemIconByID = function(itemId)
- return itemId == 777 and "item-tex" or nil
- end,
- RequestLoadItemDataByID = function() end,
- }
-
- local draftRow = getDraftRow("utility")
- draftRow._typeBtn:GetScript("OnClick")()
- setDraftText("utility", "777")
-
- draftRow = getDraftRow("utility")
- assert.are.equal("...", draftRow._previewLabel:GetText())
- assert.is_true(draftRow._previewLabel:IsShown())
- assert.is_true(draftRow._addBtn:IsShown())
- assert.is_false(draftRow._addBtn:IsEnabled())
- end)
-
- it("auto-resolves pending item drafts when item data finishes loading", function()
- local itemNames = {}
-
- _G.C_Item = {
- DoesItemExistByID = function(itemId)
- return itemId == 777
- end,
- GetItemNameByID = function(itemId)
- return itemNames[itemId]
- end,
- GetItemIconByID = function(itemId)
- return itemId == 777 and "item-tex" or nil
- end,
- RequestLoadItemDataByID = function() end,
- }
-
- local draftRow = getDraftRow("utility")
- draftRow._typeBtn:GetScript("OnClick")()
- setDraftText("utility", "777")
-
- assert.is_true(ns.ExtraIconsOptions._itemLoadFrame:IsEventRegistered("GET_ITEM_INFO_RECEIVED"))
- assert.are.equal("...", draftRow._previewLabel:GetText())
-
- itemNames[777] = "Loaded Item"
- ns.ExtraIconsOptions._itemLoadFrame:GetScript("OnEvent")(
- ns.ExtraIconsOptions._itemLoadFrame,
- "GET_ITEM_INFO_RECEIVED",
- 777,
- true
- )
-
- draftRow = getDraftRow("utility")
- assert.are.equal("Loaded Item", draftRow._previewLabel:GetText())
- assert.is_true(draftRow._addBtn:IsShown())
- assert.is_true(draftRow._addBtn:IsEnabled())
- end)
-
- it("disables builtin rows instead of removing them", function()
- refresh()
-
- local row = findVisibleRowByText("utility", "Trinket 1")
- assert.is_not_nil(row)
- assert.are.equal("x", row._deleteBtn:GetText())
-
- row._deleteBtn:GetScript("OnClick")()
-
- assert.is_true(profile.extraIcons.viewers.utility[1].disabled)
- row = findVisibleRowByText("utility", "Trinket 1")
- assert.is_not_nil(row)
- assert.are.equal("+", row._deleteBtn:GetText())
- assert.are.equal("GameFontDisable", row._label:GetFontObject())
- end)
-
- it("keeps disabled builtins in default order and locks their movement", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
- profile.extraIcons.viewers.utility = {}
- profile.extraIcons.viewers.main = {
- { stackKey = "healthstones", disabled = true },
- { kind = "spell", ids = { 12345 } },
- { stackKey = "trinket1", disabled = true },
- }
-
- refresh()
-
- assert.are.same({ "Test Spell", "Trinket 1", "Healthstones" }, getVisibleRowLabels("main"))
-
- local trinketRow = findVisibleRowByText("main", "Trinket 1")
- assert.is_not_nil(trinketRow)
- assert.is_false(trinketRow._upBtn:IsEnabled())
- assert.is_false(trinketRow._downBtn:IsEnabled())
- assert.is_false(trinketRow._moveBtn:IsEnabled())
- end)
-
- it("renders missing builtin rows as disabled placeholders in utility", function()
- profile.extraIcons.viewers.utility = {}
- profile.extraIcons.viewers.main = {}
-
- refresh()
-
- local row = findVisibleRowByText("utility", "Trinket 1")
- assert.is_not_nil(row)
- assert.are.equal("+", row._deleteBtn:GetText())
-
- row._deleteBtn:GetScript("OnClick")()
-
- assert.are.equal(1, #profile.extraIcons.viewers.utility)
- assert.are.equal("trinket1", profile.extraIcons.viewers.utility[1].stackKey)
- assert.is_nil(profile.extraIcons.viewers.utility[1].disabled)
- end)
-
- it("shows the current racial as a placeholder when absent and restores it after removal", function()
- local getShownPopupName = TestHelpers.InstallPopupAutoAccept()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- if spellId == 59752 then return "Every Man for Himself" end
- return nil
- end,
- GetSpellTexture = function(spellId)
- return spellId == 59752 and "racial-tex" or nil
- end,
- }
- profile.extraIcons.viewers.utility = {}
- profile.extraIcons.viewers.main = {}
-
- refresh()
-
- local row = findVisibleRowByText("utility", "Every Man for Himself")
- assert.is_not_nil(row)
- assert.are.equal("+", row._deleteBtn:GetText())
-
- row._deleteBtn:GetScript("OnClick")()
-
- assert.is_true(ns.ExtraIconsOptions._isRacialPresent(profile.extraIcons.viewers, 59752))
- row = findVisibleRowByText("utility", "Every Man for Himself")
- assert.is_not_nil(row)
- assert.are.equal("x", row._deleteBtn:GetText())
-
- row._deleteBtn:GetScript("OnClick")()
-
- assert.are.equal("ECM_CONFIRM_REMOVE_EXTRA_ICON", getShownPopupName())
- assert.is_false(ns.ExtraIconsOptions._isRacialPresent(profile.extraIcons.viewers, 59752))
- row = findVisibleRowByText("utility", "Every Man for Himself")
- assert.is_not_nil(row)
- assert.are.equal("+", row._deleteBtn:GetText())
- end)
-
- it("shows the current racial placeholder when UnitRace lookup misses but the racial is known", function()
- _G.UnitRace = function() return "Unknown", "Unknown", 99 end
- _G.IsPlayerSpell = function(spellId)
- return spellId == 59752
- end
- _G.C_Spell = {
- GetSpellName = function(spellId)
- if spellId == 59752 then return "Every Man for Himself" end
- return nil
- end,
- GetSpellTexture = function(spellId)
- return spellId == 59752 and "racial-tex" or nil
- end,
- }
- profile.extraIcons.viewers.utility = {}
- profile.extraIcons.viewers.main = {}
-
- refresh()
-
- local row = findVisibleRowByText("utility", "Every Man for Himself")
- assert.is_not_nil(row)
- assert.are.equal("+", row._deleteBtn:GetText())
- end)
-
- it("blocks duplicate inline additions and shows where the entry already exists", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
- profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
- profile.extraIcons.viewers.main = {}
-
- local draftRow = setDraftText("main", "12345")
-
- assert.are.equal(ns.L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(ns.L["UTILITY_VIEWER_SHORT"]), draftRow._previewLabel:GetText())
- assert.is_true(draftRow._addBtn:IsShown())
- assert.is_false(draftRow._addBtn:IsEnabled())
- assert.are.equal(0, #profile.extraIcons.viewers.main)
- end)
-
- it("does not display racials from other races", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- if spellId == 33697 then return "Blood Fury" end
- if spellId == 59752 then return "Every Man for Himself" end
- return nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
- profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 33697 } } }
-
- refresh()
-
- assert.is_nil(findVisibleRowByText("utility", "Blood Fury"))
- assert.is_not_nil(findVisibleRowByText("utility", "Every Man for Himself"))
- end)
-
- it("moves entries between viewers", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function(spellId)
- return spellId == 12345 and "spell-tex" or nil
- end,
- }
- profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
- profile.extraIcons.viewers.main = {}
-
- refresh()
-
- local row = findVisibleRowByText("utility", "Test Spell")
- row._moveBtn:GetScript("OnClick")()
-
- assert.are.equal(0, #profile.extraIcons.viewers.utility)
- assert.are.equal(1, #profile.extraIcons.viewers.main)
- end)
-
- it("blocks moving an entry into a viewer that already has the same entry", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
- profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
- profile.extraIcons.viewers.main = { { kind = "spell", ids = { 12345 } } }
-
- refresh()
-
- local row = findVisibleRowByText("utility", "Test Spell")
- assert.is_false(row._moveBtn:IsEnabled())
- row._moveBtn:GetScript("OnEnter")(row._moveBtn)
- assert.are.equal(
- ns.L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(ns.L["MAIN_VIEWER_SHORT"]),
- _G.GameTooltip._title
- )
-
- row._moveBtn:GetScript("OnClick")()
-
- assert.are.equal(1, #profile.extraIcons.viewers.utility)
- assert.are.equal(1, #profile.extraIcons.viewers.main)
- end)
-
- it("rebinds whole-row mouseover handlers on refresh", function()
- refresh()
-
- local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
- assert.is_not_nil(row)
- assert.is_not_nil(row._highlight)
- assert.is_true(row:IsMouseEnabled())
- assert.is_function(row:GetScript("OnEnter"))
- assert.is_function(row:GetScript("OnLeave"))
- assert.is_false(row._highlight:IsShown())
-
- row:GetScript("OnEnter")(row)
- assert.is_true(row._highlight:IsShown())
-
- row:GetScript("OnLeave")(row)
- assert.is_false(row._highlight:IsShown())
- end)
-
- it("aligns viewer rows with the settings label column", function()
- refresh()
-
- local row = ns.ExtraIconsOptions._viewerCanvas._viewerRowPools.utility[1]
- local point, _, _, x = row:GetPoint(1)
- assert.are.equal("TOPLEFT", point)
- assert.are.equal(37, x)
- end)
-
- it("adds extra spacing between viewer rows", function()
- refresh()
-
- local rows = getVisibleRows("utility")
- local _, _, _, _, firstY = rows[1]:GetPoint(1)
- local _, _, _, _, secondY = rows[2]:GetPoint(1)
-
- assert.are.equal(-30, secondY - firstY)
- end)
-
- it("creates inline draft rows at the same alignment", function()
- local row = getDraftRow("utility")
- local point, _, _, x = row:GetPoint(1)
- assert.are.equal("TOPLEFT", point)
- assert.are.equal(37, x)
- end)
-
- it("shows placeholder text that tracks draft type and focus", function()
- local row = getDraftRow("utility")
-
- assert.are.equal(ns.L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"], row._editBoxPlaceholder:GetText())
- assert.is_true(row._editBoxPlaceholder:IsShown())
- assert.is_false(row._addBtn:IsEnabled())
-
- row._editBox:SetFocus()
- row._editBox:GetScript("OnEditFocusGained")(row._editBox)
- assert.is_false(row._editBoxPlaceholder:IsShown())
-
- row._editBox:GetScript("OnEditFocusLost")(row._editBox)
- assert.is_true(row._editBoxPlaceholder:IsShown())
-
- row._typeBtn:GetScript("OnClick")()
-
- row = getDraftRow("utility")
- assert.are.equal(ns.L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"], row._editBoxPlaceholder:GetText())
- assert.is_true(row._editBoxPlaceholder:IsShown())
- assert.is_false(row._editBoxHasFocus)
- end)
-
- it("supports keyboard-friendly draft entry flow", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
-
- local row = getDraftRow("main")
- row._editBox:SetFocus()
- row._editBox:SetText("12345")
- row._editBox:GetScript("OnTextChanged")(row._editBox)
- row._editBox:GetScript("OnEnterPressed")(row._editBox)
-
- assert.are.equal(1, #profile.extraIcons.viewers.main)
- assert.is_true(row._editBox:HasFocus())
- assert.is_true(row._editBox:IsTextHighlighted())
-
- row._editBox:GetScript("OnTabPressed")(row._editBox)
-
- assert.are.equal("item", ns.ExtraIconsOptions._draftStates.main.kind)
- assert.is_true(row._editBox:HasFocus())
-
- row._editBox:GetScript("OnEscapePressed")(row._editBox)
- assert.is_false(row._editBox:HasFocus())
- end)
-
- it("shows explanatory tooltips for special rows", function()
- profile.extraIcons.viewers.utility = {}
- profile.extraIcons.viewers.main = {}
+ it("creates a subcategory", function()
+ assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
+ end)
- refresh()
+ it("registers a legend info row and collection widget instead of a canvas", function()
+ local opts = ns.ExtraIconsOptions
- local row = findVisibleRowByText("utility", "Trinket 1")
- row:GetScript("OnEnter")(row)
+ assert.is_nil(opts._viewerCanvas)
+ assert.is_nil(opts._draftEntryCanvas)
+ assert.is_table(opts._draftStates)
+ assert.are.equal("toggle", capturedTable.args.enabled.type)
+ assert.are.equal("info", capturedTable.args.specialRowsLegend.type)
+ assert.are.equal("collection", capturedTable.args.viewers.type)
+ assert.are.equal(ns.L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"], capturedTable.args.specialRowsLegend.value)
+ end)
- assert.are.equal("Trinket 1", _G.GameTooltip._title)
- assert.are.equal(ns.L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], _G.GameTooltip._lines[1])
- end)
+ it("builds utility and main sections with placeholder rows and trailers", function()
+ local utility = assert(getSection("utility"))
+ local main = assert(getSection("main"))
+
+ assert.are.equal(ns.L["UTILITY_VIEWER_ICONS"], utility.title)
+ assert.are.equal(ns.L["MAIN_VIEWER_ICONS"], main.title)
+ assert.is_not_nil(utility.trailer)
+ assert.is_not_nil(main.trailer)
+ assert.is_not_nil(findItem("utility", function(item)
+ return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
+ end))
+ assert.is_not_nil(findItem("utility", function(item)
+ return item.actions.delete.tooltip == ns.L["ADD_ENTRY"]
+ end))
+ end)
- it("appends spell IDs to spell-entry tooltip titles", function()
- _G.C_Spell = {
- GetSpellName = function(spellId)
- return spellId == 12345 and "Test Spell" or nil
- end,
- GetSpellTexture = function()
- return nil
- end,
- }
- profile.extraIcons.viewers.utility = { { kind = "spell", ids = { 12345 } } }
+ it("uses trailer callbacks to add custom entries per viewer", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
+ }
- refresh()
+ local trailer = assert(getSection("main")).trailer
+ trailer.onTextChanged("12345")
+
+ trailer = assert(getSection("main")).trailer
+ assert.are.equal("Test Spell", trailer.previewText)
+ assert.is_true(trailer.submitEnabled)
+ assert.is_true(trailer.onSubmit())
+
+ assert.are.equal("", ns.ExtraIconsOptions._draftStates.main.idText)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+ assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
+ assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
+ local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
+ assert.are.same({ category, category }, refreshCalls)
+ assert.are.same({ "OptionsChanged" }, scheduledReasons)
+ end)
- local row = findVisibleRowByText("utility", "Test Spell")
- row:GetScript("OnEnter")(row)
+ it("shows pending item previews and refreshes when item data arrives", function()
+ local itemNames = {}
+
+ _G.C_Item = {
+ DoesItemExistByID = function(itemId)
+ return itemId == 777
+ end,
+ GetItemNameByID = function(itemId)
+ return itemNames[itemId]
+ end,
+ GetItemIconByID = function(itemId)
+ return itemId == 777 and "item-tex" or nil
+ end,
+ RequestLoadItemDataByID = function() end,
+ }
- assert.are.equal("Test Spell (spell ID 12345)", _G.GameTooltip._title)
- end)
+ local trailer = assert(getSection("utility")).trailer
+ trailer.onToggleMode()
+ trailer = assert(getSection("utility")).trailer
+ trailer.onTextChanged("777")
+
+ trailer = assert(getSection("utility")).trailer
+ assert.are.equal("...", trailer.previewText)
+ assert.is_false(trailer.submitEnabled)
+
+ itemNames[777] = "Loaded Item"
+ ns.ExtraIconsOptions._itemLoadFrame:GetScript("OnEvent")(
+ ns.ExtraIconsOptions._itemLoadFrame,
+ "GET_ITEM_INFO_RECEIVED",
+ 777,
+ true
+ )
+
+ trailer = assert(getSection("utility")).trailer
+ assert.are.equal("Loaded Item", trailer.previewText)
+ assert.is_true(trailer.submitEnabled)
+ end)
- it("appends item IDs to item-entry tooltip titles", function()
- _G.C_Item = {
- DoesItemExistByID = function(itemId)
- return itemId == 99999
- end,
- GetItemNameByID = function(itemId)
- return itemId == 99999 and "Test Item" or nil
- end,
- GetItemIconByID = function()
- return nil
- end,
- RequestLoadItemDataByID = function() end,
- }
- profile.extraIcons.viewers.utility = { { kind = "item", ids = { { itemID = 99999 } } } }
+ it("blocks duplicate entries and shows which viewer already owns them", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
+ }
+ profile.extraIcons.viewers.utility = {
+ { kind = "spell", ids = { 12345 } },
+ }
- refresh()
+ local trailer = assert(getSection("main")).trailer
+ trailer.onTextChanged("12345")
+ trailer = assert(getSection("main")).trailer
- local row = findVisibleRowByText("utility", "Test Item")
- row:GetScript("OnEnter")(row)
+ assert.are.equal(
+ ns.L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(ns.L["UTILITY_VIEWER_SHORT"]),
+ trailer.previewText
+ )
+ assert.is_false(trailer.submitEnabled)
+ end)
- assert.are.equal("Test Item (item ID 99999)", _G.GameTooltip._title)
- end)
+ it("reorder, move, and remove actions operate on the stored viewers", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return ({
+ [12345] = "Spell A",
+ [23456] = "Spell B",
+ })[spellId]
+ end,
+ GetSpellTexture = function(spellId)
+ return "spell-" .. tostring(spellId)
+ end,
+ }
+ profile.extraIcons.viewers.utility = {
+ { kind = "spell", ids = { 12345 } },
+ { kind = "spell", ids = { 23456 } },
+ }
- it("uses Hide for active built-in row button tooltips", function()
- refresh()
+ local spellA = assert(findItem("utility", function(item)
+ return item.label == "Spell A"
+ end))
+ spellA.actions.down.onClick()
+ assert.are.same({ 23456 }, profile.extraIcons.viewers.utility[1].ids)
+
+ spellA = assert(findItem("utility", function(item)
+ return item.label == "Spell A"
+ end))
+ spellA.actions.move.onClick()
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.equal(1, #profile.extraIcons.viewers.main)
+
+ local moved = assert(findItem("main", function(item)
+ return item.label == "Spell A"
+ end))
+ moved.actions.delete.onClick()
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+ end)
- local row = findVisibleRowByText("utility", "Trinket 1")
- row._deleteBtn:GetScript("OnEnter")(row._deleteBtn)
+ it("keeps disabled builtins at the end of the active list in builtin order", function()
+ profile.extraIcons.viewers.main = {
+ { stackKey = "healthstones", disabled = true },
+ { kind = "spell", ids = { 59752 } },
+ { stackKey = "trinket1", disabled = true },
+ }
- assert.are.equal(ns.L["EXTRA_ICONS_HIDE_TOOLTIP"], _G.GameTooltip._title)
- end)
+ local labels = {}
+ for _, item in ipairs(assert(getSection("main")).items) do
+ labels[#labels + 1] = item.label
+ end
- it("does not add built-in enabled text to active built-in row tooltips", function()
- refresh()
+ assert.are.same({
+ "Spell 59752",
+ "Trinket 1",
+ "Healthstones",
+ }, labels)
+ end)
- local row = findVisibleRowByText("utility", "Trinket 1")
- row:GetScript("OnEnter")(row)
+ it("shows placeholder tooltips and duplicate-move tooltips", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return "spell-" .. tostring(spellId)
+ end,
+ }
+ profile.extraIcons.viewers.utility = {
+ { kind = "spell", ids = { 12345 } },
+ }
+ profile.extraIcons.viewers.main = {
+ { kind = "spell", ids = { 12345 } },
+ }
- assert.are.equal("Trinket 1", _G.GameTooltip._title)
- assert.are.same({}, _G.GameTooltip._lines)
- end)
+ local placeholder = assert(findItem("utility", function(item)
+ return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
+ end))
+ placeholder.onEnter(CreateFrame("Frame"))
+ assert.are.equal(ns.L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], _G.GameTooltip._lines[1])
+
+ local duplicateMove = assert(findItem("utility", function(item)
+ return item.label == "Test Spell"
+ end))
+ assert.are.equal(
+ ns.L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(ns.L["MAIN_VIEWER_SHORT"]),
+ duplicateMove.actions.move.tooltip()
+ )
end)
end)
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index 6893c5d9..709dc632 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -11,238 +11,37 @@ describe("PowerBarTickMarksOptions", function()
local originalGlobals
local ns
- local function makeFontString()
- local text = ""
- local width = 0
- local shown = true
- return {
- SetPoint = function() end,
- ClearAllPoints = function() end,
- SetWidth = function(_, value)
- width = value
- end,
- GetWidth = function()
- return width
- end,
- SetJustifyH = function() end,
- SetWordWrap = function() end,
- SetText = function(_, value)
- text = value
- end,
- GetText = function()
- return text
- end,
- Show = function()
- shown = true
- end,
- Hide = function()
- shown = false
- end,
- IsShown = function()
- return shown
- end,
- }
- end
-
- local function makeTexture()
- return {
- SetAllPoints = function() end,
- SetColorTexture = function() end,
- Show = function() end,
- Hide = function() end,
- }
- end
-
- local function makeFrame()
- local scripts = {}
- local shown = true
- local text = ""
- return {
- SetHeight = function() end,
- SetWidth = function() end,
- SetSize = function() end,
- SetPoint = function() end,
- SetAllPoints = function() end,
- ClearAllPoints = function() end,
- Show = function()
- shown = true
- end,
- Hide = function()
- shown = false
- end,
- IsShown = function()
- return shown
- end,
- EnableMouse = function() end,
- SetEnabled = function() end,
- RegisterForClicks = function() end,
- SetAutoFocus = function() end,
- SetNumeric = function() end,
- SetText = function(_, value)
- text = value
- end,
- GetText = function()
- return text
- end,
- SetColorRGB = function() end,
- SetDataProvider = function() end,
- SetWordWrap = function() end,
- SetJustifyH = function() end,
- SetFocus = function(self)
- self._focused = true
- end,
- ClearFocus = function(self)
- self._focused = false
- end,
- HighlightText = function(self)
- self._highlighted = true
- end,
- SetScript = function(_, event, fn)
- scripts[event] = fn
- end,
- GetScript = function(_, event)
- return scripts[event]
- end,
- CreateFontString = function()
- return makeFontString()
- end,
- CreateTexture = function()
- return makeTexture()
- end,
- }
- end
-
- local function makeSlider()
- local value
- local callbacks = {}
- local slider = makeFrame()
- local minValue = 0
- local maxValue = 0
- local stepValue = 1
- slider.RightText = makeFontString()
- slider.MinText = makeFontString()
- slider.MaxText = makeFontString()
- slider.Slider = {
- SetValueStep = function(_, step)
- stepValue = step
- end,
- GetValueStep = function()
- return stepValue
- end,
- GetMinMaxValues = function()
- return minValue, maxValue
- end,
- }
-
- slider.Init = function(_, initialValue, initialMin, initialMax, _, _)
- value = initialValue
- minValue = initialMin
- maxValue = initialMax
- end
- slider.RegisterCallback = function(_, event, fn, owner)
- callbacks[event] = callbacks[event] or {}
- callbacks[event][#callbacks[event] + 1] = { fn = fn, owner = owner }
- end
- slider.SetValue = function(self, newValue)
- value = newValue
- for _, callback in ipairs(callbacks.OnValueChanged or {}) do
- callback.fn(callback.owner or self, newValue)
- end
- end
- slider.GetValue = function()
- return value
- end
-
- return slider
- end
-
- local function registerSettingsWithHarness()
- local defaultWidthSlider
- local defaultWidthText
- local viewInitializer
-
- _G.MinimalSliderWithSteppersMixin = {
- Label = { Right = 1 },
- Event = { OnValueChanged = "OnValueChanged" },
- }
-
- _G.CreateFrame = function(frameType)
- if frameType == "Slider" then
- return makeSlider()
- end
- return makeFrame()
- end
- _G.CreateDataProvider = function()
- return {
- Flush = function() end,
- Insert = function() end,
- }
- end
+ local function registerSettings(parentCategory)
+ local captured
+ local refreshCalls = {}
+ local fakeCategory = {}
local SB = {
- CreateColorSwatch = function()
- return makeFrame()
+ RegisterFromTable = function(tbl)
+ captured = tbl
end,
- CreateCanvasLayout = function()
- local layout = { frame = makeFrame() }
-
- function layout:AddHeader()
- local row = makeFrame()
- row._defaultsButton = makeFrame()
- return row
- end
-
- function layout:AddSpacer() end
-
- function layout:AddDescription(text)
- local row = makeFrame()
- row._text = makeFontString()
- row._text:SetText(text)
- return row
- end
-
- function layout:AddColorSwatch()
- local row = makeFrame()
- local swatch = makeFrame()
- return row, swatch
- end
-
- function layout:AddSlider()
- local row = makeFrame()
- local slider = makeSlider()
- local valueText = makeFontString()
- defaultWidthSlider = slider
- defaultWidthText = valueText
- return row, slider, valueText
- end
-
- function layout:AddButton()
- local row = makeFrame()
- local button = makeFrame()
- return row, button
- end
-
- function layout:AddScrollList()
- local view = {}
- view.SetElementInitializer = function(_, _, fn)
- viewInitializer = fn
- end
- return makeFrame(), makeFrame(), view
+ GetSubcategory = function(name)
+ if name == "Tick Marks" then
+ return fakeCategory
end
-
- return layout
+ end,
+ RefreshCategory = function(category)
+ refreshCalls[#refreshCalls + 1] = category
end,
}
- ns.PowerBarTickMarksOptions.RegisterSettings(SB, {})
+ ns.PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
- return defaultWidthSlider, defaultWidthText, viewInitializer
+ return captured, refreshCalls, fakeCategory
end
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
- "MinimalSliderWithSteppersMixin",
- "StaticPopupDialogs", "YES", "NO", "SETTINGS_DEFAULTS",
+ "StaticPopupDialogs",
+ "StaticPopup_Show",
+ "YES",
+ "NO",
+ "SETTINGS_DEFAULTS",
})
end)
@@ -250,9 +49,11 @@ describe("PowerBarTickMarksOptions", function()
TestHelpers.RestoreGlobals(originalGlobals)
end)
- it("module loads and exposes RegisterSettings and Store", function()
+ before_each(function()
ns = TestHelpers.SetupPowerBarTickMarksEnv()
+ end)
+ it("module loads and exposes RegisterSettings and Store", function()
assert.is_table(ns.PowerBarTickMarksOptions)
assert.is_function(ns.PowerBarTickMarksOptions.RegisterSettings)
@@ -261,90 +62,111 @@ describe("PowerBarTickMarksOptions", function()
assert.is_function(ns.PowerBarTickMarksStore.AddTick)
end)
- it("supports drag and manual entry for default and per-tick sliders", function()
- ns = TestHelpers.SetupPowerBarTickMarksEnv()
- ns.Runtime = { ScheduleLayoutUpdate = function() end }
+ it("registers a subcategory with collection-based tick editors", function()
+ local parentCategory = {}
+ local captured = registerSettings(parentCategory)
- local defaultWidthSlider, defaultWidthText, viewInitializer = registerSettingsWithHarness()
+ assert.are.equal("Tick Marks", captured.name)
+ assert.are.equal(parentCategory, captured.parentCategory)
+ assert.are.equal("header", captured.args.tickMarksHeader.type)
+ assert.are.equal("collection", captured.args.tickCollection.type)
+ assert.are.equal("editor", captured.args.tickCollection.preset)
+ assert.are.equal(320, captured.args.tickCollection.height)
+ end)
- defaultWidthSlider:SetValue(4)
- assert.are.equal(4, ns.PowerBarTickMarksStore.GetDefaultWidth())
- assert.are.equal("4", defaultWidthText:GetText())
+ it("add button appends a tick using the current defaults", function()
+ local scheduledReason
+ ns.Runtime = {
+ ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
+ end,
+ }
+
+ local captured, refreshCalls, fakeCategory = registerSettings({})
+
+ captured.args.addTick.onClick()
- defaultWidthSlider._ecmValueButton:GetScript("OnClick")()
- assert.is_true(defaultWidthSlider._ecmEditBox:IsShown())
- defaultWidthSlider._ecmEditBox:SetText("5")
- defaultWidthSlider._ecmEditBox:GetScript("OnEnterPressed")()
- assert.are.equal(5, ns.PowerBarTickMarksStore.GetDefaultWidth())
- assert.are.equal("5", defaultWidthText:GetText())
- assert.is_false(defaultWidthSlider._ecmEditBox:IsShown())
+ local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
+ assert.are.equal(1, #ticks)
+ assert.are.equal(50, ticks[1].value)
+ assert.are.equal(ns.PowerBarTickMarksStore.GetDefaultWidth(), ticks[1].width)
+ assert.are.same(ns.PowerBarTickMarksStore.GetDefaultColor(), ticks[1].color)
+ assert.are.equal("OptionsChanged", scheduledReason)
+ assert.are.same({ fakeCategory }, refreshCalls)
+ end)
+
+ it("defaults action clears the current spec ticks after confirmation", function()
+ local shownPopup
+ local scheduledReason
+ _G.StaticPopup_Show = function(name, _, _, data)
+ shownPopup = name
+ local dialog = _G.StaticPopupDialogs[name]
+ dialog.OnAccept(nil, data)
+ end
+ ns.Runtime = {
+ ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
+ end,
+ }
ns.PowerBarTickMarksStore.SetCurrentTicks({
{ value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
})
- local rowFrame = makeFrame()
- local tick = ns.PowerBarTickMarksStore.GetCurrentTicks()[1]
- viewInitializer(rowFrame, { index = 1, tick = tick })
-
- rowFrame._valueSlider:SetValue(75)
+ local captured, refreshCalls, fakeCategory = registerSettings({})
+ local defaultsAction = captured.args.tickMarksHeader.actions[1]
- rowFrame._widthSlider._ecmValueButton:GetScript("OnClick")()
- rowFrame._widthSlider._ecmEditBox:SetText("4")
- rowFrame._widthSlider._ecmEditBox:GetScript("OnEnterPressed")()
+ defaultsAction.onClick()
- tick = ns.PowerBarTickMarksStore.GetCurrentTicks()[1]
- assert.are.equal(75, tick.value)
- assert.are.equal(4, tick.width)
- assert.are.equal("75", rowFrame._valueText:GetText())
- assert.are.equal("4", rowFrame._widthText:GetText())
- assert.is_false(rowFrame._widthSlider._ecmEditBox:IsShown())
+ assert.are.equal("ECM_CONFIRM_CLEAR_TICKS", shownPopup)
+ assert.are.same({}, ns.PowerBarTickMarksStore.GetCurrentTicks())
+ assert.are.equal("OptionsChanged", scheduledReason)
+ assert.are.same({ fakeCategory }, refreshCalls)
end)
- it("rescales value slider for large resource values", function()
- ns = TestHelpers.SetupPowerBarTickMarksEnv()
- ns.Runtime = { ScheduleLayoutUpdate = function() end }
-
- local _, _, viewInitializer = registerSettingsWithHarness()
-
+ it("collection editor callbacks update tick values, widths, and removal", function()
+ local scheduledReasons = {}
+ ns.Runtime = {
+ ScheduleLayoutUpdate = function(_, reason)
+ scheduledReasons[#scheduledReasons + 1] = reason
+ end,
+ }
ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 50000, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
+ { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
})
- local rowFrame = makeFrame()
- local tick = ns.PowerBarTickMarksStore.GetCurrentTicks()[1]
- viewInitializer(rowFrame, { index = 1, tick = tick })
+ local captured, refreshCalls = registerSettings({})
+ local item = captured.args.tickCollection.items()[1]
- -- Slider should have rescaled to accommodate 50000
- assert.are.equal("50000", rowFrame._valueText:GetText())
- assert.are.equal(50000, rowFrame._valueSlider._ecmMaxValue)
- assert.are.equal(250, rowFrame._valueSlider._ecmStep)
+ item.fields[1].onValueChanged(75)
+ item.fields[2].onValueChanged(4)
- -- Typing a value in a higher tier should rescale further
- rowFrame._valueSlider._ecmValueButton:GetScript("OnClick")()
- rowFrame._valueSlider._ecmEditBox:SetText("120000")
- rowFrame._isRefreshing = false
- rowFrame._valueSlider._ecmEditBox:GetScript("OnEnterPressed")()
- assert.are.equal(500000, rowFrame._valueSlider._ecmMaxValue)
- assert.are.equal(2500, rowFrame._valueSlider._ecmStep)
- end)
+ local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
+ assert.are.equal(75, ticks[1].value)
+ assert.are.equal(4, ticks[1].width)
- it("keeps small values in the default 1-200 tier", function()
- ns = TestHelpers.SetupPowerBarTickMarksEnv()
- ns.Runtime = { ScheduleLayoutUpdate = function() end }
+ item.remove.onClick()
- local _, _, viewInitializer = registerSettingsWithHarness()
+ assert.are.same({}, ns.PowerBarTickMarksStore.GetCurrentTicks())
+ assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
+ assert.are.equal(3, #refreshCalls)
+ end)
+ it("rescales the value field range for large resource values", function()
ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 30, width = 1, color = { r = 1, g = 1, b = 1, a = 1 } },
+ { value = 50000, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
})
- local rowFrame = makeFrame()
- local tick = ns.PowerBarTickMarksStore.GetCurrentTicks()[1]
- viewInitializer(rowFrame, { index = 1, tick = tick })
-
- assert.are.equal("30", rowFrame._valueText:GetText())
- assert.are.equal(200, rowFrame._valueSlider._ecmMaxValue)
- assert.are.equal(1, rowFrame._valueSlider._ecmStep)
+ local captured = registerSettings({})
+ local item = captured.args.tickCollection.items()[1]
+ local minValue, maxValue, step = item.fields[1].getRange(item, 50000)
+ local nextMin, nextMax, nextStep = item.fields[1].getRange(item, 120000)
+
+ assert.are.equal(1, minValue)
+ assert.are.equal(50000, maxValue)
+ assert.are.equal(250, step)
+ assert.are.equal(1, nextMin)
+ assert.are.equal(500000, nextMax)
+ assert.are.equal(2500, nextStep)
end)
end)
diff --git a/Tests/UI/ProfileOptions_spec.lua b/Tests/UI/ProfileOptions_spec.lua
index a007446f..4feb0f1b 100644
--- a/Tests/UI/ProfileOptions_spec.lua
+++ b/Tests/UI/ProfileOptions_spec.lua
@@ -9,7 +9,7 @@ local TestHelpers = assert(
describe("ProfileOptions getters/setters/defaults", function()
local originalGlobals
- local profile, defaults, SB, ns, settings, profileCategory, initializers
+ local profile, defaults, SB, ns, settings, profileCategory, initializers, refreshCalls
setup(function()
originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
@@ -23,6 +23,10 @@ describe("ProfileOptions getters/setters/defaults", function()
TestHelpers.SetupOptionsGlobals()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+ refreshCalls = {}
+ SB.RefreshCategory = function(category)
+ refreshCalls[#refreshCalls + 1] = category
+ end
-- Profile module needs import/export stubs
ns.ImportExport = {
@@ -50,6 +54,10 @@ describe("ProfileOptions getters/setters/defaults", function()
settings["ECM_ProfileSwitch"]:SetValue("Other")
assert.are.equal("Other", called)
end)
+ it("setter refreshes the category", function()
+ settings["ECM_ProfileSwitch"]:SetValue("Other")
+ assert.are.same({ profileCategory }, refreshCalls)
+ end)
it("uses the localized New Profile row label", function()
local newProfileButton = assert(TestHelpers.FindButtonInitializer(initializers, ns.L["NEW_PROFILE"]))
@@ -65,6 +73,7 @@ describe("ProfileOptions getters/setters/defaults", function()
assert.are.equal("ECM_NEW_PROFILE", getShown())
assert.are.equal("MyCustomProfile", switched)
+ assert.are.same({ profileCategory, profileCategory }, refreshCalls)
end)
end)
@@ -131,6 +140,7 @@ describe("ProfileOptions getters/setters/defaults", function()
assert.are.equal("ECM_CONFIRM_COPY_PROFILE", getShown())
assert.are.equal("Other", copied)
+ assert.are.same({ profileCategory }, refreshCalls)
end)
it("Copy does nothing when selection is empty", function()
@@ -157,6 +167,7 @@ describe("ProfileOptions getters/setters/defaults", function()
assert.are.equal("ECM_CONFIRM_DELETE_PROFILE", getShown())
assert.are.equal("Other", deleted)
+ assert.are.same({ profileCategory }, refreshCalls)
end)
it("Delete does nothing when selection is empty", function()
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index de7e22b2..e228f216 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -8,8 +8,7 @@ local L = ns.L
local REMOVE_STALE_SPELL_COLORS_POPUP = "ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS"
local SPELL_COLORS_HEADER_BUTTON_WIDTH = 100
-local SPELL_COLORS_HEADER_BUTTON_HEIGHT = 22
-local SPELL_COLORS_HEADER_BUTTON_SPACING = 8
+local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
--- Generates the merged list of spell color rows from spell color entries.
---@param entries { key: ECM_SpellColorKey }[]|nil
@@ -111,22 +110,6 @@ local function collectIncompleteSpellColorRows(rows)
return incompleteRows
end
----@param owner Frame
----@param text string
-local function setSimpleTooltip(owner, text)
- owner:SetScript("OnEnter", function(self)
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- GameTooltip:SetText(text, 1, 1, 1, true)
- GameTooltip:Show()
- end)
- owner:SetScript("OnLeave", function()
- GameTooltip_Hide()
- end)
-end
-
---@param key ECM_SpellColorKey|table|nil
---@return string
local function getSpellColorRowName(key)
@@ -181,10 +164,10 @@ local function maybeShowSpellColorKeyTooltip(owner, data)
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
- GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], 1, 1, 1)
+ GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], TOOLTIP_TITLE_COLOR, 1)
for _, line in ipairs(lines) do
- GameTooltip:AddLine(line, nil, nil, nil, true)
+ GameTooltip:AddLine(line, TOOLTIP_TITLE_COLOR, true)
end
GameTooltip:Show()
@@ -194,16 +177,12 @@ end
-- Canvas Frame for Spell Colors
--------------------------------------------------------------------------------
-StaticPopupDialogs["ECM_CONFIRM_RESET_SPELL_COLORS"] = ns.OptionUtil.MakeConfirmDialog(L["SPELL_COLORS_RESET_CONFIRM"])
-
local function createSpellColorCanvas(SB, subcatName)
- local layout = SB.CreateCanvasLayout(subcatName)
- local frame = layout.frame
-
local function resetAllSpellColors()
ns.SpellColors.ClearCurrentSpecColors()
- frame:RefreshSpellList()
+ ns.SpellColors.SetDefaultColor(C.BUFFBARS_DEFAULT_COLOR)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ SB.RefreshCategory(subcatName)
end
local function reconcileSpellColors()
@@ -232,189 +211,19 @@ local function createSpellColorCanvas(SB, subcatName)
ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key)))
end
- frame:RefreshSpellList()
if #removedKeys > 0 then
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ SB.RefreshCategory(subcatName)
end
end
)
end
- -- Header — uses SettingsListTemplate's built-in Title, divider, and DefaultsButton
- local headerRow = layout:AddHeader(subcatName)
- local defaultsBtn = headerRow._defaultsButton
- local reconcileBtn = CreateFrame("Button", nil, headerRow, "UIPanelButtonTemplate")
- local removeStaleBtn = CreateFrame("Button", nil, headerRow, "UIPanelButtonTemplate")
-
- reconcileBtn:SetSize(SPELL_COLORS_HEADER_BUTTON_WIDTH, SPELL_COLORS_HEADER_BUTTON_HEIGHT)
- reconcileBtn:SetPoint("RIGHT", defaultsBtn, "LEFT", -SPELL_COLORS_HEADER_BUTTON_SPACING, 0)
- reconcileBtn:SetText(L["SPELL_COLORS_RECONCILE_BUTTON"])
- reconcileBtn:SetScript("OnClick", function()
- if not reconcileBtn:IsEnabled() then
- return
- end
- reconcileSpellColors()
- end)
-
- removeStaleBtn:SetSize(SPELL_COLORS_HEADER_BUTTON_WIDTH, SPELL_COLORS_HEADER_BUTTON_HEIGHT)
- removeStaleBtn:SetPoint("RIGHT", reconcileBtn, "LEFT", -SPELL_COLORS_HEADER_BUTTON_SPACING, 0)
- removeStaleBtn:SetText(L["SPELL_COLORS_REMOVE_STALE_BUTTON"])
- removeStaleBtn:SetScript("OnClick", function()
- if not removeStaleBtn:IsEnabled() then
- return
- end
- removeStaleSpellColors()
- end)
- setSimpleTooltip(removeStaleBtn, L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"])
-
- defaultsBtn:SetText(SETTINGS_DEFAULTS)
- defaultsBtn:SetScript("OnClick", function()
- if ns.Addon.BuffBars:IsEditLocked() then
- return
- end
- StaticPopup_Show("ECM_CONFIRM_RESET_SPELL_COLORS", nil, nil, {
- onAccept = resetAllSpellColors,
- })
- end)
-
- layout:AddSpacer(2)
-
- local descRow = layout:AddDescription(L["SPELL_COLORS_DESC"], "GameFontHighlight")
- descRow._text:SetWordWrap(true)
-
- local warningRow = layout:AddDescription("")
- local warningText = warningRow._text
- warningText:SetWordWrap(true)
-
- -- Default color swatch (above the per-spell list)
- local defaultColorRow, defaultColorSwatch = layout:AddColorSwatch("Default color")
- -- Reposition the default color swatch to align with the scroll list rows below,
- -- which are indented by the canvas label margin (37px from canvas edge).
- defaultColorRow._label:ClearAllPoints()
- defaultColorRow._label:SetPoint("LEFT", 74, 0)
- defaultColorRow._label:SetPoint("RIGHT", defaultColorRow, "CENTER", -85, 0)
- defaultColorSwatch:ClearAllPoints()
- defaultColorSwatch:SetPoint("LEFT", defaultColorRow, "CENTER", -70, 0)
- defaultColorSwatch:SetScript("OnClick", function()
- if ns.Addon.BuffBars:IsEditLocked() then
- return
- end
- local c = ns.SpellColors.GetDefaultColor()
- ns.OptionUtil.OpenColorPicker(c, false, function(color)
- ns.SpellColors.SetDefaultColor(color)
- defaultColorSwatch:SetColorRGB(color.r, color.g, color.b)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end)
- end)
-
- -- Scroll list using Blizzard's SettingsColorSwatchControlTemplate per element
- local scrollTopY = layout.yPos
- local scrollBox, scrollBar, view = layout:AddScrollList(C.SCROLL_ROW_HEIGHT_COMPACT)
-
- scrollBox:ClearAllPoints()
- scrollBox:SetPoint("TOPLEFT", 37, scrollTopY)
- scrollBox:SetPoint("BOTTOMRIGHT", -30, C.SPELL_COLORS_SCROLL_BOTTOM_OFFSET_WITH_SECRET_NAMES)
- scrollBar:ClearAllPoints()
- scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
- scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
-
- local secretNameDescRow = layout:_CreateRow(C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT)
- local secretNameDescText = secretNameDescRow:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
- secretNameDescText:SetPoint("TOPLEFT", 37, 0)
- secretNameDescText:SetPoint("TOPRIGHT", secretNameDescRow, "TOPRIGHT", -10, 0)
- secretNameDescText:SetJustifyH("LEFT")
- secretNameDescText:SetWordWrap(true)
- secretNameDescText:SetText(L["SPELL_COLORS_SECRET_NAMES_DESC"])
- secretNameDescRow._text = secretNameDescText
- secretNameDescRow:ClearAllPoints()
- secretNameDescRow:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, C.SPELL_COLORS_SECRET_NAMES_DESC_BOTTOM_OFFSET)
- secretNameDescRow:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, C.SPELL_COLORS_SECRET_NAMES_DESC_BOTTOM_OFFSET)
- secretNameDescRow:Hide()
-
- local secretNameButtonRow, secretNameReloadButton = layout:AddButton("", L["SPELL_COLORS_RELOAD_BUTTON"])
- secretNameButtonRow._label:SetText("")
- secretNameButtonRow:ClearAllPoints()
- secretNameButtonRow:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, C.SPELL_COLORS_SECRET_NAMES_BUTTON_BOTTOM_OFFSET)
- secretNameButtonRow:SetPoint(
- "BOTTOMRIGHT",
- frame,
- "BOTTOMRIGHT",
- 0,
- C.SPELL_COLORS_SECRET_NAMES_BUTTON_BOTTOM_OFFSET
- )
- secretNameButtonRow:Hide()
- secretNameReloadButton:SetScript("OnClick", function()
- ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
- end)
-
- frame._secretNameDescRow = secretNameDescRow
- frame._secretNameReloadButtonRow = secretNameButtonRow
- frame._secretNameReloadButton = secretNameReloadButton
- frame._reconcileButton = reconcileBtn
- frame._removeStaleButton = removeStaleBtn
- frame._spellColorListView = view
-
- view:SetElementInitializer("SettingsColorSwatchControlTemplate", function(control, data)
- -- Position label (matches SettingsListElementMixin:Init positioning)
- if not control._ecmPositioned then
- control.Text:SetFontObject(GameFontNormal)
- control.Text:ClearAllPoints()
- control.Text:SetPoint("LEFT", 37, 0)
- control.Text:SetPoint("RIGHT", control, "CENTER", -85, 0)
- control._ecmPositioned = true
- end
-
- if not control._ecmSpellColorTooltipHooked then
- if control.EnableMouse then
- control:EnableMouse(true)
- end
- control:HookScript("OnEnter", function(self)
- maybeShowSpellColorKeyTooltip(self, self._ecmSpellColorRowData)
- end)
- control:HookScript("OnLeave", function()
- GameTooltip_Hide()
- end)
- control._ecmSpellColorTooltipHooked = true
- end
-
- control._ecmSpellColorRowData = data
-
- local name = getSpellColorRowName(data.key)
- local label = data.textureFileID and ("|T" .. data.textureFileID .. ":14:14|t " .. name) or name
- control.Text:SetText(label)
-
- local c = ns.SpellColors.GetColorByKey(data.key) or ns.SpellColors.GetDefaultColor()
- control.ColorSwatch:SetColorRGB(c.r, c.g, c.b)
-
- control.ColorSwatch:SetScript("OnClick", function()
- if ns.Addon.BuffBars:IsEditLocked() then
- return
- end
- local current = ns.SpellColors.GetColorByKey(data.key) or ns.SpellColors.GetDefaultColor()
- ns.OptionUtil.OpenColorPicker(current, false, function(color)
- ns.SpellColors.SetColorByKey(data.key, color)
- control.ColorSwatch:SetColorRGB(color.r, color.g, color.b)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end)
- end)
- end)
-
- local dataProvider = CreateDataProvider()
- scrollBox:SetDataProvider(dataProvider)
-
- function frame:RefreshSpellList()
- local rows = buildSpellColorRows(ns.SpellColors.GetAllColorEntries())
- local secretNameFooterState = getSecretNameFooterState(rows)
- local hasIncompleteRows = hasRowsNeedingReconcile(rows)
- local canReconcile = hasIncompleteRows and not isSpellColorsReloadRestricted()
- local canRemoveStale = hasIncompleteRows and not isSpellColorsReloadRestricted()
-
- dataProvider:Flush()
- for _, row in ipairs(rows) do
- dataProvider:Insert(row)
- end
+ local function getRows()
+ return buildSpellColorRows(ns.SpellColors.GetAllColorEntries())
+ end
- -- Build warning text
+ local function getWarningText()
local parts = {}
local locked, reason = ns.Addon.BuffBars:IsEditLocked()
if locked and reason == "combat" then
@@ -422,33 +231,158 @@ local function createSpellColorCanvas(SB, subcatName)
elseif locked and reason == "secrets" then
parts[#parts + 1] = L["SPELL_COLORS_SECRETS_WARNING"]
end
- warningText:SetText(table.concat(parts, "\n"))
-
- if secretNameFooterState.show then
- secretNameDescRow:Show()
- secretNameButtonRow:Show()
- else
- secretNameDescRow:Hide()
- secretNameButtonRow:Hide()
- end
- secretNameReloadButton:SetEnabled(secretNameFooterState.enabled)
-
- local dc = ns.SpellColors.GetDefaultColor()
- defaultColorSwatch:SetColorRGB(dc.r, dc.g, dc.b)
-
- defaultsBtn:SetEnabled(not locked)
- reconcileBtn:SetEnabled(canReconcile)
- removeStaleBtn:SetEnabled(canRemoveStale)
+ return table.concat(parts, "\n")
end
- -- Blizzard's panel calls OnDefault on canvas frames during global defaults
- frame.OnDefault = resetAllSpellColors
+ local function buildSpellColorItems()
+ local items = {}
- frame:SetScript("OnShow", function(self)
- self:RefreshSpellList()
- end)
+ items[#items + 1] = {
+ label = L["DEFAULT_COLOR"],
+ color = {
+ value = ns.SpellColors.GetDefaultColor(),
+ enabled = function()
+ local locked = ns.Addon.BuffBars:IsEditLocked()
+ return not locked
+ end,
+ onClick = function()
+ if ns.Addon.BuffBars:IsEditLocked() then
+ return
+ end
+ ns.OptionUtil.OpenColorPicker(ns.SpellColors.GetDefaultColor(), false, function(color)
+ ns.SpellColors.SetDefaultColor(color)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ SB.RefreshCategory(subcatName)
+ end)
+ end,
+ },
+ }
+
+ for _, row in ipairs(getRows()) do
+ items[#items + 1] = {
+ label = getSpellColorRowName(row.key),
+ icon = row.textureFileID,
+ color = {
+ value = ns.SpellColors.GetColorByKey(row.key) or ns.SpellColors.GetDefaultColor(),
+ onClick = function()
+ if ns.Addon.BuffBars:IsEditLocked() then
+ return
+ end
+ local current = ns.SpellColors.GetColorByKey(row.key) or ns.SpellColors.GetDefaultColor()
+ ns.OptionUtil.OpenColorPicker(current, false, function(color)
+ ns.SpellColors.SetColorByKey(row.key, color)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ SB.RefreshCategory(subcatName)
+ end)
+ end,
+ },
+ onEnter = function(owner)
+ maybeShowSpellColorKeyTooltip(owner, row)
+ end,
+ onLeave = function()
+ GameTooltip_Hide()
+ end,
+ }
+ end
+ return items
+ end
- return frame
+ SB.RegisterFromTable({
+ name = subcatName,
+ args = {
+ spellColorsHeader = {
+ type = "header",
+ name = subcatName,
+ actions = {
+ {
+ text = L["SPELL_COLORS_RECONCILE_BUTTON"],
+ width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
+ enabled = function()
+ local rows = getRows()
+ return hasRowsNeedingReconcile(rows) and not isSpellColorsReloadRestricted()
+ end,
+ onClick = function()
+ if hasRowsNeedingReconcile(getRows()) and not isSpellColorsReloadRestricted() then
+ reconcileSpellColors()
+ end
+ end,
+ },
+ {
+ text = L["SPELL_COLORS_REMOVE_STALE_BUTTON"],
+ width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
+ tooltip = L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
+ enabled = function()
+ local rows = getRows()
+ return hasRowsNeedingReconcile(rows) and not isSpellColorsReloadRestricted()
+ end,
+ onClick = function()
+ if hasRowsNeedingReconcile(getRows()) and not isSpellColorsReloadRestricted() then
+ removeStaleSpellColors()
+ end
+ end,
+ },
+ },
+ order = 1,
+ },
+ spellColorsDescription = {
+ type = "info",
+ name = "",
+ value = L["SPELL_COLORS_DESC"],
+ wide = true,
+ multiline = true,
+ height = 36,
+ order = 2,
+ },
+ spellColorsWarning = {
+ type = "info",
+ name = "",
+ value = getWarningText,
+ wide = true,
+ multiline = true,
+ height = 30,
+ hidden = function()
+ return getWarningText() == ""
+ end,
+ order = 3,
+ },
+ spellColorCollection = {
+ type = "collection",
+ preset = "swatch",
+ height = 260,
+ rowHeight = C.SCROLL_ROW_HEIGHT_COMPACT,
+ items = buildSpellColorItems,
+ onDefault = resetAllSpellColors,
+ order = 4,
+ },
+ secretNameDescription = {
+ type = "info",
+ name = "",
+ value = L["SPELL_COLORS_SECRET_NAMES_DESC"],
+ wide = true,
+ multiline = true,
+ height = C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT,
+ hidden = function()
+ return not getSecretNameFooterState(getRows()).show
+ end,
+ order = 5,
+ },
+ secretNameReload = {
+ type = "button",
+ name = " ",
+ buttonText = L["SPELL_COLORS_RELOAD_BUTTON"],
+ hidden = function()
+ return not getSecretNameFooterState(getRows()).show
+ end,
+ disabled = function()
+ return not getSecretNameFooterState(getRows()).enabled
+ end,
+ onClick = function()
+ ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
+ end,
+ order = 6,
+ },
+ },
+ })
end
--------------------------------------------------------------------------------
@@ -533,17 +467,6 @@ function BuffBarsOptions.RegisterSettings(SB)
})
createSpellColorCanvas(SB, L["SPELL_COLORS_SUBCAT"])
-
- SB.Button({
- name = L["CONFIGURE_SPELL_COLORS"],
- buttonText = L["OPEN"],
- onClick = function()
- local catID = SB.GetSubcategoryID(L["SPELL_COLORS_SUBCAT"])
- if catID then
- Settings.OpenToCategory(catID)
- end
- end,
- })
end
ns.SettingsBuilder.RegisterSection(ns, "BuffBars", BuffBarsOptions)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 04eefce5..231688c6 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -10,23 +10,13 @@ local BUILTIN_STACKS = C.BUILTIN_STACKS
local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
local RACIAL_ABILITIES = C.RACIAL_ABILITIES
-local ROW_HEIGHT = 26
-local ICON_SIZE = 20
-local BTN_SIZE = 22
-local DRAFT_ENTRY_ROW_HEIGHT = 28
-local DRAFT_ENTRY_PREVIEW_ICON_SIZE = 16
-local DRAFT_TYPE_BUTTON_WIDTH = 58
-local DRAFT_ID_BOX_WIDTH = 120
-local DRAFT_ADD_BUTTON_WIDTH = 44
local TOOLTIP_ITEM_ICON_SIZE = 14
local TOOLTIP_QUALITY_ICON_SIZE = 14
-local SETTINGS_LABEL_X = 37
-local SPECIAL_ROWS_LEGEND_HEIGHT = 24
-local VIEWER_ROW_SPACING = 4
-local VIEWER_CANVAS_HEIGHT = 448
+local VIEWER_COLLECTION_HEIGHT = 448
local DEFAULT_SPECIAL_VIEWER = "utility"
local DRAFT_PENDING_TEXT = "..."
local VIEWER_ORDER = { "utility", "main" }
+local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
local VIEWER_LABELS = {
utility = "UTILITY_VIEWER_ICONS",
main = "MAIN_VIEWER_ICONS",
@@ -234,7 +224,7 @@ local function addItemStackTooltipLines(entry)
return false
end
- GameTooltip:AddLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], nil, nil, nil, true)
+ GameTooltip:AddLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], TOOLTIP_TITLE_COLOR, true)
for _, itemEntry in ipairs(stack.ids) do
local itemId = getItemIdFromEntry(itemEntry)
@@ -247,9 +237,7 @@ local function addItemStackTooltipLines(entry)
itemName or ("Item " .. tostring(itemId)),
getQualityMarkup(quality)
),
- 1,
- 1,
- 1
+ TOOLTIP_TITLE_COLOR
)
end
@@ -684,21 +672,9 @@ end
--------------------------------------------------------------------------------
--- Set a simple text tooltip on a button.
-local function setButtonTooltip(btn, text)
- btn:SetScript("OnEnter", function(self)
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- GameTooltip:SetText(text, 1, 1, 1)
- GameTooltip:Show()
- end)
- btn:SetScript("OnLeave", GameTooltip_Hide)
-end
-
local function addTooltipLine(text)
if text and text ~= "" then
- GameTooltip:AddLine(text, nil, nil, nil, true)
+ GameTooltip:AddLine(text, TOOLTIP_TITLE_COLOR, true)
end
end
@@ -712,7 +688,7 @@ local function showRowTooltip(owner, rowData)
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
- GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1)
+ GameTooltip:SetText(getEntryTooltipTitle(displayEntry), TOOLTIP_TITLE_COLOR, 1)
if rowData.isBuiltin then
if rowData.isPlaceholder then
@@ -730,40 +706,6 @@ local function showRowTooltip(owner, rowData)
GameTooltip:Show()
end
-local function clearRowMouseover(row)
- row:SetScript("OnEnter", nil)
- row:SetScript("OnLeave", nil)
- if row._highlight then
- row._highlight:Hide()
- end
- if row.EnableMouse then
- row:EnableMouse(false)
- end
-end
-
-local function setRowMouseover(row, tooltipBuilder)
- if row._highlight then
- row._highlight:Hide()
- end
- if row.EnableMouse then
- row:EnableMouse(true)
- end
- row:SetScript("OnEnter", function(self)
- if self._highlight then
- self._highlight:Show()
- end
- if tooltipBuilder then
- tooltipBuilder(self)
- end
- end)
- row:SetScript("OnLeave", function(self)
- if self._highlight then
- self._highlight:Hide()
- end
- GameTooltip_Hide()
- end)
-end
-
--- Check if a racial entry belongs to the current player character.
function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
local spellId = getEntrySpellId(entry)
@@ -771,145 +713,13 @@ function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
local racial = getCurrentRacialEntry()
if not racial then return true end
for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry ~= racial then
- if spellId == racialEntry.spellId then
- return false
- end
+ if racialEntry ~= racial and spellId == racialEntry.spellId then
+ return false
end
end
return true
end
---------------------------------------------------------------------------------
--- UI: Entry Row Factory
---------------------------------------------------------------------------------
-
-local function createEntryRow(parent)
- local row = CreateFrame("Frame", nil, parent)
- row:SetHeight(ROW_HEIGHT)
-
- row._highlight = row:CreateTexture(nil, "BACKGROUND")
- row._highlight:SetAllPoints()
- row._highlight:SetColorTexture(1, 1, 1, 0.08)
- row._highlight:Hide()
-
- row._icon = row:CreateTexture(nil, "ARTWORK")
- row._icon:SetSize(ICON_SIZE, ICON_SIZE)
- row._icon:SetPoint("LEFT", 0, 0)
-
- row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
- row._label:SetJustifyH("LEFT")
- row._label:SetWordWrap(false)
-
- row._deleteBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._deleteBtn:SetSize(BTN_SIZE, BTN_SIZE)
- row._deleteBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
- row._deleteBtn:SetText("x")
-
- row._moveBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._moveBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
- row._moveBtn:SetPoint("RIGHT", row._deleteBtn, "LEFT", -2, 0)
-
- row._downBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._downBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
- row._downBtn:SetPoint("RIGHT", row._moveBtn, "LEFT", -2, 0)
- row._downBtn:SetText("v")
-
- row._upBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._upBtn:SetSize(BTN_SIZE + 4, BTN_SIZE)
- row._upBtn:SetPoint("RIGHT", row._downBtn, "LEFT", -2, 0)
- row._upBtn:SetText("^")
-
- row._label:SetPoint("RIGHT", row._upBtn, "LEFT", -6, 0)
-
- return row
-end
-
-local function createDraftRow(parent)
- local row = CreateFrame("Frame", nil, parent)
- row:SetHeight(DRAFT_ENTRY_ROW_HEIGHT)
-
- row._typeBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._typeBtn:SetSize(DRAFT_TYPE_BUTTON_WIDTH, BTN_SIZE)
- row._typeBtn:SetPoint("LEFT", row, "LEFT", 0, 0)
-
- row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
- row._editBox:SetPoint("LEFT", row._typeBtn, "RIGHT", 6, 0)
- row._editBox:SetSize(DRAFT_ID_BOX_WIDTH, 20)
- row._editBox:SetAutoFocus(false)
- if type(row._editBox.SetNumeric) == "function" then
- row._editBox:SetNumeric(true)
- end
- if type(row._editBox.SetMaxLetters) == "function" then
- row._editBox:SetMaxLetters(10)
- end
- if type(row._editBox.SetTextInsets) == "function" then
- row._editBox:SetTextInsets(6, 6, 0, 0)
- end
-
- row._editBoxPlaceholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable")
- row._editBoxPlaceholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0)
- row._editBoxPlaceholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0)
- row._editBoxPlaceholder:SetJustifyH("LEFT")
- row._editBoxPlaceholder:SetWordWrap(false)
-
- row._previewIcon = row:CreateTexture(nil, "ARTWORK")
- row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
- row._previewIcon:SetSize(DRAFT_ENTRY_PREVIEW_ICON_SIZE, DRAFT_ENTRY_PREVIEW_ICON_SIZE)
- row._previewIcon:Hide()
-
- row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0)
- row._previewLabel:SetJustifyH("LEFT")
- row._previewLabel:SetWordWrap(false)
- row._previewLabel:Hide()
-
- row._addBtn = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._addBtn:SetSize(DRAFT_ADD_BUTTON_WIDTH, BTN_SIZE)
- row._addBtn:SetPoint("RIGHT", row, "RIGHT", 0, 0)
- row._addBtn:SetText(L["ADD_ENTRY"])
-
- row._previewLabel:SetPoint("RIGHT", row._addBtn, "LEFT", -6, 0)
-
- return row
-end
-
-local function createViewerHeaderRow(parent, SB, text, headerHeight)
- local row = CreateFrame("Frame", nil, parent)
- row:SetHeight(headerHeight)
- row._title = SB.CreateSubheaderTitle(row, text)
- return row
-end
-
---------------------------------------------------------------------------------
--- Embedded Content: Viewer lists
---------------------------------------------------------------------------------
-
-local function createViewerListCanvas(SB, headerHeight)
- local frame = CreateFrame("Frame")
- frame:SetHeight(VIEWER_CANVAS_HEIGHT)
-
- frame._viewerRowPools = { utility = {}, main = {} }
- frame._viewerDraftRows = {}
- frame._viewerHeaders = {}
- frame._viewerEmptyLabels = {}
- frame._legendLabel = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- frame._legendLabel:SetJustifyH("LEFT")
- frame._legendLabel:SetWordWrap(true)
- frame._legendLabel:SetText(L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"])
-
- for _, vk in ipairs(VIEWER_ORDER) do
- frame._viewerHeaders[vk] = createViewerHeaderRow(frame, SB, L[VIEWER_LABELS[vk]], headerHeight)
-
- frame._viewerEmptyLabels[vk] = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable")
- frame._viewerEmptyLabels[vk]:SetJustifyH("LEFT")
- frame._viewerEmptyLabels[vk]:SetText(L["EXTRA_ICONS_NO_ENTRIES"])
- end
-
- return frame
-end
-
--------------------------------------------------------------------------------
-- Settings Registration
--------------------------------------------------------------------------------
@@ -919,13 +729,8 @@ StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
function ExtraIconsOptions.RegisterSettings(SB)
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
- local viewerHeaderHeight = (SB.SetCanvasLayoutDefaults and SB.SetCanvasLayoutDefaults().headerHeight) or 50
- local viewerCanvas = createViewerListCanvas(SB, viewerHeaderHeight)
-
- ExtraIconsOptions._viewerCanvas = viewerCanvas
- ExtraIconsOptions._draftEntryCanvas = nil
- ExtraIconsOptions._addFormCanvas = nil
- ExtraIconsOptions._presetsCanvas = nil
+ local categoryName = L["EXTRA_ICONS"]
+ local category
local function getProfile()
return ns.Addon.db.profile
@@ -935,6 +740,14 @@ function ExtraIconsOptions.RegisterSettings(SB)
return getProfile().extraIcons.viewers
end
+ local function refreshCategory()
+ if category then
+ SB.RefreshCategory(category)
+ else
+ SB.RefreshCategory(categoryName)
+ end
+ end
+
ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
local draftStates = ExtraIconsOptions._draftStates
for _, viewerKey in ipairs(VIEWER_ORDER) do
@@ -956,9 +769,7 @@ function ExtraIconsOptions.RegisterSettings(SB)
itemLoadFrame:SetScript("OnEvent", function(_, _, itemId)
if itemId and ExtraIconsOptions._pendingItemLoads[itemId] then
ExtraIconsOptions._pendingItemLoads[itemId] = nil
- if ExtraIconsOptions._refresh then
- ExtraIconsOptions._refresh()
- end
+ refreshCategory()
end
end)
itemLoadFrame._ecmHooked = true
@@ -968,23 +779,6 @@ function ExtraIconsOptions.RegisterSettings(SB)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
- local function refreshVisibleSettingsControls()
- local panel = SettingsPanel
- if not panel or not panel.IsShown or not panel:IsShown() then
- return
- end
-
- local settingsList = panel.GetSettingsList and panel:GetSettingsList()
- local scrollBox = settingsList and settingsList.ScrollBox
- if scrollBox and scrollBox.ForEachFrame then
- scrollBox:ForEachFrame(function(frame)
- if frame.EvaluateState then
- frame:EvaluateState()
- end
- end)
- end
- end
-
local function resetDraftStates()
for _, viewerKey in ipairs(VIEWER_ORDER) do
draftStates[viewerKey].kind = "spell"
@@ -1005,37 +799,6 @@ function ExtraIconsOptions.RegisterSettings(SB)
return L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
end
- local function refreshDraftPlaceholder(row, viewerKey)
- if not (row and row._editBoxPlaceholder) then
- return
- end
-
- local draftState = draftStates[viewerKey]
- local idText = draftState and draftState.idText or ""
- row._editBoxPlaceholder:SetText(getDraftPlaceholderText(draftState))
-
- if row._editBoxHasFocus or idText ~= "" then
- row._editBoxPlaceholder:Hide()
- else
- row._editBoxPlaceholder:Show()
- end
- end
-
- local function focusDraftEditBox(row)
- if not (row and row._editBox) then
- return
- end
-
- if row._editBox.SetFocus then
- row._editBox:SetFocus()
- end
- row._editBoxHasFocus = true
- refreshDraftPlaceholder(row, row._viewerKey)
- if row._editBox.HighlightText then
- row._editBox:HighlightText()
- end
- end
-
local function getDraftDuplicateInfo(viewerKey)
local draftState = draftStates[viewerKey]
local id = ExtraIconsOptions._parseSingleId(draftState.idText)
@@ -1054,18 +817,18 @@ function ExtraIconsOptions.RegisterSettings(SB)
return not isDuplicate
end
- local function addDraftEntry(viewerKey, row)
+ local function addDraftEntry(viewerKey)
local draftState = draftStates[viewerKey]
local id = ExtraIconsOptions._parseSingleId(draftState.idText)
if not id or not canAddDraftEntry(viewerKey) then
- return
+ return false
end
ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, draftState.kind, { id })
draftState.idText = ""
scheduleUpdate()
- ExtraIconsOptions._refresh()
- focusDraftEditBox(row)
+ refreshCategory()
+ return true
end
local function restoreDefaultExtraIcons()
@@ -1078,168 +841,10 @@ function ExtraIconsOptions.RegisterSettings(SB)
ns.Addon.db.profile.extraIcons = ns.CloneValue(defaultsConfig)
resetDraftStates()
scheduleUpdate()
- ExtraIconsOptions._refresh()
- end
-
- local function setRowVisualState(row, isDisabledRow)
- local alpha = isDisabledRow and 0.55 or 1
- if row._label and type(row._label.SetFontObject) == "function" then
- local fontObject = isDisabledRow and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal
- if fontObject then
- row._label:SetFontObject(fontObject)
- end
- end
- if row._label and type(row._label.SetAlpha) == "function" then
- row._label:SetAlpha(alpha)
- end
- if row._icon and type(row._icon.SetAlpha) == "function" then
- row._icon:SetAlpha(alpha)
- end
- if row._icon and type(row._icon.SetDesaturated) == "function" then
- row._icon:SetDesaturated(isDisabledRow)
- end
- if row._icon and type(row._icon.SetVertexColor) == "function" then
- if isDisabledRow then
- row._icon:SetVertexColor(0.6, 0.6, 0.6, 1)
- else
- row._icon:SetVertexColor(1, 1, 1, 1)
- end
- end
- if row._label and type(row._label.SetTextColor) == "function" then
- if isDisabledRow then
- row._label:SetTextColor(0.65, 0.65, 0.65, 1)
- else
- row._label:SetTextColor(1, 0.82, 0, 1)
- end
- end
- end
-
- local function ensureDraftRow(viewerKey)
- local row = viewerCanvas._viewerDraftRows[viewerKey]
- if row then
- return row
- end
-
- row = createDraftRow(viewerCanvas)
- viewerCanvas._viewerDraftRows[viewerKey] = row
- row._viewerKey = viewerKey
-
- setButtonTooltip(row._typeBtn, L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"])
- setButtonTooltip(row._addBtn, L["ADD_ENTRY"])
-
- row._typeBtn:SetScript("OnClick", function()
- if isDisabled() then
- return
- end
-
- local draftState = draftStates[viewerKey]
- draftState.kind = draftState.kind == "spell" and "item" or "spell"
- ExtraIconsOptions._refresh()
- end)
-
- row._editBox:SetScript("OnTextChanged", function(self)
- if row._syncingText then
- return
- end
-
- draftStates[viewerKey].idText = self:GetText() or ""
- ExtraIconsOptions._refresh()
- end)
-
- row._editBox:SetScript("OnEnterPressed", function()
- addDraftEntry(viewerKey, row)
- end)
-
- row._editBox:SetScript("OnTabPressed", function(self)
- if isDisabled() then
- return
- end
-
- local draftState = draftStates[viewerKey]
- draftState.kind = draftState.kind == "spell" and "item" or "spell"
- ExtraIconsOptions._refresh()
- focusDraftEditBox(row)
- end)
-
- row._editBox:SetScript("OnEditFocusGained", function()
- row._editBoxHasFocus = true
- refreshDraftPlaceholder(row, viewerKey)
- if row._editBox.HighlightText and (row._editBox:GetText() or "") ~= "" then
- row._editBox:HighlightText()
- end
- end)
-
- row._editBox:SetScript("OnEditFocusLost", function()
- row._editBoxHasFocus = false
- refreshDraftPlaceholder(row, viewerKey)
- end)
-
- row._editBox:SetScript("OnEscapePressed", function(self)
- if self.ClearFocus then
- self:ClearFocus()
- end
- row._editBoxHasFocus = false
- refreshDraftPlaceholder(row, viewerKey)
- end)
-
- row._addBtn:SetScript("OnClick", function()
- addDraftEntry(viewerKey, row)
- end)
-
- return row
+ refreshCategory()
end
- local function refreshDraftRow(viewerKey, row)
- local draftState = draftStates[viewerKey]
- local controlsDisabled = isDisabled()
- local status, name, icon = getDraftResolution(viewerKey)
- local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
-
- row._typeBtn:SetText(draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"])
- if row._typeBtn.SetEnabled then
- row._typeBtn:SetEnabled(not controlsDisabled)
- end
-
- if row._editBox.GetText and row._editBox:GetText() ~= draftState.idText then
- row._syncingText = true
- row._editBox:SetText(draftState.idText)
- row._syncingText = nil
- end
- if row._editBox.SetEnabled then
- row._editBox:SetEnabled(not controlsDisabled)
- end
- refreshDraftPlaceholder(row, viewerKey)
-
- if icon then
- row._previewIcon:SetTexture(icon)
- row._previewIcon:Show()
- else
- row._previewIcon:SetTexture(nil)
- row._previewIcon:Hide()
- end
-
- if status == "resolved" and isDuplicate then
- row._previewLabel:SetText(
- L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey)))
- row._previewLabel:Show()
- elseif status == "resolved" then
- row._previewLabel:SetText(name or "")
- row._previewLabel:Show()
- elseif status == "pending" then
- row._previewLabel:SetText(DRAFT_PENDING_TEXT)
- row._previewLabel:Show()
- else
- row._previewLabel:SetText("")
- row._previewLabel:Hide()
- end
-
- row._addBtn:Show()
- if row._addBtn.SetEnabled then
- row._addBtn:SetEnabled(not controlsDisabled and status == "resolved" and not isDuplicate)
- end
- end
-
- local function configureEntryRow(row, rowData)
+ local function buildActionItem(rowData)
local controlsDisabled = isDisabled()
local displayEntry = rowData.displayEntry
local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
@@ -1250,61 +855,23 @@ function ExtraIconsOptions.RegisterSettings(SB)
local positionLocked = rowData.isBuiltin and rowData.isDisabled
local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not positionLocked
local canMove = not controlsDisabled and rowData.index ~= nil and not positionLocked and not hasMoveDuplicate
-
- row._label:SetText(ExtraIconsOptions._getEntryName(displayEntry))
- row._icon:SetTexture(ExtraIconsOptions._getEntryIcon(displayEntry) or 134400)
- setRowVisualState(row, rowData.isDisabled)
-
- row._upBtn:SetEnabled(canReorder and rowData.activeIndex > 1)
- row._downBtn:SetEnabled(canReorder and rowData.activeIndex < rowData.activeCount)
- row._moveBtn:SetEnabled(canMove)
- row._deleteBtn:SetEnabled(not controlsDisabled)
- row._moveBtn:SetText(rowData.viewerKey == "utility" and ">" or "<")
-
- setButtonTooltip(row._upBtn, L["MOVE_UP_TOOLTIP"])
- setButtonTooltip(row._downBtn, L["MOVE_DOWN_TOOLTIP"])
- if hasMoveDuplicate then
- setButtonTooltip(row._moveBtn, L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer)))
- elseif positionLocked then
- setButtonTooltip(row._moveBtn, L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
- else
- setButtonTooltip(row._moveBtn, L["MOVE_TO_VIEWER_TOOLTIP"]:format(otherViewer))
+ local deleteText = "x"
+ local deleteTooltip = L["REMOVE_TOOLTIP"]
+ local deleteAction = function()
+ local entryName = ExtraIconsOptions._getEntryName(displayEntry)
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
+ onAccept = function()
+ ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ })
end
- row._upBtn:SetScript("OnClick", function()
- if rowData.index == nil or not canReorder then
- return
- end
-
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
-
- row._downBtn:SetScript("OnClick", function()
- if rowData.index == nil or not canReorder then
- return
- end
-
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
-
- row._moveBtn:SetScript("OnClick", function()
- if rowData.index == nil or not canMove then
- return
- end
-
- ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
- scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
-
if rowData.isBuiltin then
- row._deleteBtn:SetText(rowData.isDisabled and "+" or "x")
- setButtonTooltip(row._deleteBtn, rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"])
- row._deleteBtn:SetScript("OnClick", function()
+ deleteText = rowData.isDisabled and "+" or "x"
+ deleteTooltip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
+ deleteAction = function()
ExtraIconsOptions._toggleBuiltinRow(
getProfile(),
rowData.viewerKey,
@@ -1312,119 +879,188 @@ function ExtraIconsOptions.RegisterSettings(SB)
rowData.stackKey or displayEntry.stackKey
)
scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
+ refreshCategory()
+ end
elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- row._deleteBtn:SetText("+")
- setButtonTooltip(row._deleteBtn, L["ADD_ENTRY"])
- row._deleteBtn:SetScript("OnClick", function()
+ deleteText = "+"
+ deleteTooltip = L["ADD_ENTRY"]
+ deleteAction = function()
ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
scheduleUpdate()
- ExtraIconsOptions._refresh()
- end)
- else
- row._deleteBtn:SetText("x")
- setButtonTooltip(row._deleteBtn, L["REMOVE_TOOLTIP"])
- row._deleteBtn:SetScript("OnClick", function()
- local entryName = ExtraIconsOptions._getEntryName(displayEntry)
- StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
- onAccept = function()
- ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
+ refreshCategory()
+ end
+ end
+
+ return {
+ label = ExtraIconsOptions._getEntryName(displayEntry),
+ icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
+ alpha = rowData.isDisabled and 0.55 or 1,
+ labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
+ labelColor = rowData.isDisabled and { 0.65, 0.65, 0.65, 1 } or { 1, 0.82, 0, 1 },
+ iconDesaturated = rowData.isDisabled == true,
+ iconVertexColor = rowData.isDisabled and { 0.6, 0.6, 0.6, 1 } or nil,
+ onEnter = function(owner)
+ showRowTooltip(owner, rowData)
+ end,
+ onLeave = function()
+ GameTooltip_Hide()
+ end,
+ actions = {
+ up = {
+ text = "^",
+ width = 30,
+ enabled = canReorder and rowData.activeIndex > 1,
+ tooltip = L["MOVE_UP_TOOLTIP"],
+ onClick = function()
+ if rowData.index == nil or not canReorder then
+ return
+ end
+
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
scheduleUpdate()
- ExtraIconsOptions._refresh()
+ refreshCategory()
end,
- })
- end)
- end
+ },
+ down = {
+ text = "v",
+ width = 30,
+ enabled = canReorder and rowData.activeIndex < rowData.activeCount,
+ tooltip = L["MOVE_DOWN_TOOLTIP"],
+ onClick = function()
+ if rowData.index == nil or not canReorder then
+ return
+ end
+
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ },
+ move = {
+ text = rowData.viewerKey == "utility" and ">" or "<",
+ width = 30,
+ enabled = canMove,
+ tooltip = function()
+ if hasMoveDuplicate then
+ return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
+ end
+ if positionLocked then
+ return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
+ end
+ return L["MOVE_TO_VIEWER_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
+ end,
+ onClick = function()
+ if rowData.index == nil or not canMove then
+ return
+ end
- clearRowMouseover(row)
- setRowMouseover(row, function(self)
- showRowTooltip(self, rowData)
- end)
+ ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ },
+ delete = {
+ text = deleteText,
+ width = 26,
+ enabled = not controlsDisabled,
+ tooltip = deleteTooltip,
+ onClick = deleteAction,
+ },
+ },
+ }
end
- viewerCanvas.OnDefault = restoreDefaultExtraIcons
+ local function buildModeInputTrailer(viewerKey)
+ local draftState = draftStates[viewerKey]
+ local controlsDisabled = isDisabled()
+ local status, name, icon = getDraftResolution(viewerKey)
+ local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
+ local previewText
- --------------------------------------------------------------------
- -- Refresh: viewer lists canvas
- --------------------------------------------------------------------
- local function refreshViewerLists()
- local viewers = getViewers()
- local y = 0
+ if status == "resolved" and isDuplicate then
+ previewText = L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey))
+ elseif status == "resolved" then
+ previewText = name or ""
+ elseif status == "pending" then
+ previewText = DRAFT_PENDING_TEXT
+ end
- viewerCanvas._legendLabel:ClearAllPoints()
- viewerCanvas._legendLabel:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
- viewerCanvas._legendLabel:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
- viewerCanvas._legendLabel:Show()
- y = y - SPECIAL_ROWS_LEGEND_HEIGHT
+ return {
+ preset = "modeInput",
+ disabled = controlsDisabled,
+ modeText = draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"],
+ modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
+ inputText = draftState.idText,
+ placeholder = getDraftPlaceholderText(draftState),
+ previewIcon = icon,
+ previewText = previewText,
+ submitText = L["ADD_ENTRY"],
+ submitTooltip = L["ADD_ENTRY"],
+ submitEnabled = status == "resolved" and not isDuplicate,
+ onToggleMode = function()
+ if controlsDisabled then
+ return
+ end
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- local headerRow = viewerCanvas._viewerHeaders[viewerKey]
- headerRow:ClearAllPoints()
- headerRow:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", 0, y)
- headerRow:SetPoint("RIGHT", viewerCanvas, "RIGHT", 0, 0)
- headerRow:Show()
- y = y - viewerHeaderHeight
-
- local pool = viewerCanvas._viewerRowPools[viewerKey]
- local rows = ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
+ draftState.kind = draftState.kind == "spell" and "item" or "spell"
+ refreshCategory()
+ end,
+ onTextChanged = function(text)
+ draftState.idText = text or ""
+ refreshCategory()
+ end,
+ onSubmit = function()
+ if controlsDisabled then
+ return false
+ end
- for _, row in ipairs(pool) do
- clearRowMouseover(row)
- row:Hide()
- end
+ return addDraftEntry(viewerKey)
+ end,
+ onTabPressed = function()
+ if controlsDisabled then
+ return false
+ end
- if #rows == 0 then
- viewerCanvas._viewerEmptyLabels[viewerKey]:ClearAllPoints()
- viewerCanvas._viewerEmptyLabels[viewerKey]:SetPoint(
- "TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
- viewerCanvas._viewerEmptyLabels[viewerKey]:Show()
- y = y - ROW_HEIGHT - VIEWER_ROW_SPACING
- else
- viewerCanvas._viewerEmptyLabels[viewerKey]:Hide()
- end
+ draftState.kind = draftState.kind == "spell" and "item" or "spell"
+ refreshCategory()
+ return true
+ end,
+ }
+ end
- for rowIndex, rowData in ipairs(rows) do
- local row = pool[rowIndex]
- if not row then
- row = createEntryRow(viewerCanvas)
- pool[rowIndex] = row
- end
+ local function buildViewerSections()
+ local viewers = getViewers()
+ local sections = {}
- configureEntryRow(row, rowData)
- row:ClearAllPoints()
- row:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
- row:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
- row:Show()
- y = y - ROW_HEIGHT - VIEWER_ROW_SPACING
- end
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
+ local items = {}
- local draftRow = ensureDraftRow(viewerKey)
- refreshDraftRow(viewerKey, draftRow)
- draftRow:ClearAllPoints()
- draftRow:SetPoint("TOPLEFT", viewerCanvas, "TOPLEFT", SETTINGS_LABEL_X, y)
- draftRow:SetPoint("RIGHT", viewerCanvas, "RIGHT", -20, 0)
- draftRow:Show()
- y = y - DRAFT_ENTRY_ROW_HEIGHT
+ for _, rowData in ipairs(rows) do
+ items[#items + 1] = buildActionItem(rowData)
+ end
- y = y - 12
+ sections[#sections + 1] = {
+ key = viewerKey,
+ title = L[VIEWER_LABELS[viewerKey]],
+ items = items,
+ emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
+ trailer = buildModeInputTrailer(viewerKey),
+ }
end
- end
- --------------------------------------------------------------------
- -- Combined refresh
- --------------------------------------------------------------------
- function ExtraIconsOptions._refresh()
- refreshViewerLists()
- refreshVisibleSettingsControls()
+ return sections
end
+ ExtraIconsOptions._viewerCanvas = nil
+ ExtraIconsOptions._draftEntryCanvas = nil
+ ExtraIconsOptions._addFormCanvas = nil
+ ExtraIconsOptions._presetsCanvas = nil
+ ExtraIconsOptions._refresh = refreshCategory
+
SB.RegisterFromTable({
- name = L["EXTRA_ICONS"],
+ name = categoryName,
path = "extraIcons",
- onShow = function()
- ExtraIconsOptions._refresh()
- end,
args = {
enabled = {
type = "toggle",
@@ -1437,17 +1073,28 @@ function ExtraIconsOptions.RegisterSettings(SB)
handler(value)
end,
},
+ specialRowsLegend = {
+ type = "info",
+ name = "",
+ value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
+ wide = true,
+ multiline = true,
+ height = 24,
+ order = 10,
+ },
viewers = {
- type = "canvas",
- canvas = viewerCanvas,
- height = VIEWER_CANVAS_HEIGHT,
+ type = "collection",
+ height = VIEWER_COLLECTION_HEIGHT,
disabled = isDisabled,
- order = 10,
+ sections = buildViewerSections,
+ onDefault = restoreDefaultExtraIcons,
+ order = 11,
},
},
})
- ExtraIconsOptions._category = SB.GetSubcategory(L["EXTRA_ICONS"])
+ category = SB.GetSubcategory(categoryName)
+ ExtraIconsOptions._category = category
end
ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
diff --git a/UI/Options.lua b/UI/Options.lua
index 749bd27a..dec34678 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -16,10 +16,6 @@ end
local CURSEFORGE_URL = "https://www.curseforge.com/wow/addons/enhanced-cooldown-manager"
local GITHUB_URL = "https://github.com/argium/EnhancedCooldownManager"
-local BUTTON_X = 37 -- matches the settings info-row title anchor
-local BUTTON_HEIGHT = 26
-local BUTTON_WIDTH = 200
-
--------------------------------------------------------------------------------
-- SettingsBuilder instance
--------------------------------------------------------------------------------
@@ -65,38 +61,10 @@ ns.SettingsBuilder = LSB:New({
local About = {}
-local function createLinksCanvas()
- local frame = CreateFrame("Frame")
- frame:SetHeight(BUTTON_HEIGHT * 2)
-
- local curseforge = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
- curseforge:SetSize(BUTTON_WIDTH, BUTTON_HEIGHT)
- curseforge:SetPoint("TOPLEFT", BUTTON_X, 0)
- curseforge:SetText(L["CURSEFORGE"])
- curseforge:SetScript("OnClick", function()
- ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
- end)
-
- local github = CreateFrame("Button", nil, frame, "UIPanelButtonTemplate")
- github:SetSize(BUTTON_WIDTH, BUTTON_HEIGHT)
- github:SetPoint("TOPLEFT", BUTTON_X, -BUTTON_HEIGHT)
- github:SetText(L["GITHUB"])
- github:SetScript("OnClick", function()
- ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
- end)
-
- frame._curseforge = curseforge
- frame._github = github
-
- return frame
-end
-
function About.RegisterSettings(SB)
local version = (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
local authorText = ns.ColorUtil.Sparkle("Argi")
- local linksCanvas = createLinksCanvas()
-
SB.RegisterFromTable({
name = L["ADDON_NAME"],
rootCategory = true,
@@ -124,12 +92,24 @@ function About.RegisterSettings(SB)
name = L["LINKS"],
order = 9,
},
- links = {
- type = "canvas",
- canvas = linksCanvas,
- height = BUTTON_HEIGHT * 2,
+ curseforge = {
+ type = "button",
+ name = L["CURSEFORGE"],
+ buttonText = L["CURSEFORGE"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
+ end,
order = 10,
},
+ github = {
+ type = "button",
+ name = L["GITHUB"],
+ buttonText = L["GITHUB"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
+ end,
+ order = 11,
+ },
},
})
end
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index afaa542f..ecd546bd 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -106,10 +106,6 @@ end
StaticPopupDialogs["ECM_CONFIRM_CLEAR_TICKS"] = ns.OptionUtil.MakeConfirmDialog(L["TICK_MARKS_CLEAR_CONFIRM"])
-local function roundToStep(value)
- return math.floor(value + 0.5)
-end
-
local function getValueSliderRange(currentValue)
for _, tier in ipairs(C.VALUE_SLIDER_TIERS) do
if currentValue <= tier.ceiling then
@@ -120,350 +116,203 @@ local function getValueSliderRange(currentValue)
return math.ceil(currentValue / last.step) * last.step, last.step
end
-local function roundSliderValue(value, step, minValue, maxValue)
- local actualStep = step or 1
- local baseValue = minValue or 0
- local rounded = math.floor(((value - baseValue) / actualStep) + 0.5) * actualStep + baseValue
- if minValue then
- rounded = math.max(minValue, rounded)
- end
- if maxValue then
- rounded = math.min(maxValue, rounded)
- end
- return rounded
-end
-
-local function getSliderStepCount(minValue, maxValue, step)
- return math.max(1, math.floor(((maxValue - minValue) / (step or 1)) + 0.5))
-end
-
-local function createSliderFormatters()
- if not MinimalSliderWithSteppersMixin or not MinimalSliderWithSteppersMixin.Label then
- return nil
- end
-
- return {
- [MinimalSliderWithSteppersMixin.Label.Right] = function()
- return ""
- end,
- }
-end
-
-local function attachSliderValueEditor(slider, textLabel, editBoxWidth)
- if slider._ecmValueButton then
- return
- end
-
- local function hideEditBox()
- if slider._ecmEditBox and slider._ecmEditBox.ClearFocus then
- slider._ecmEditBox:ClearFocus()
- end
- if slider._ecmEditBox then
- slider._ecmEditBox:Hide()
- end
- textLabel:Show()
- end
-
- local function applyEditBoxValue()
- local editBox = slider._ecmEditBox
- local enteredValue = editBox and tonumber(editBox:GetText())
- if enteredValue then
- local clamped = math.max(slider._ecmMinValue or 1, math.floor(enteredValue + 0.5))
- if slider._ecmRescale then
- slider._ecmRescale(clamped)
- end
- slider:SetValue(roundSliderValue(clamped, slider._ecmStep, slider._ecmMinValue, slider._ecmMaxValue))
- end
- hideEditBox()
- end
-
- local valueButton = CreateFrame("Button", nil, slider)
- valueButton:RegisterForClicks("LeftButtonDown")
- valueButton:SetAllPoints(textLabel)
- slider._ecmValueButton = valueButton
-
- local editBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate")
- editBox:SetAutoFocus(false)
- editBox:SetNumeric(false)
- editBox:SetSize(editBoxWidth, 20)
- editBox:SetPoint("CENTER", textLabel, "CENTER")
- editBox:SetJustifyH("CENTER")
- editBox:Hide()
- slider._ecmEditBox = editBox
-
- editBox:SetScript("OnEnterPressed", applyEditBoxValue)
- editBox:SetScript("OnEscapePressed", hideEditBox)
- editBox:SetScript("OnEditFocusLost", hideEditBox)
-
- valueButton:SetScript("OnClick", function()
- valueButton:ClearAllPoints()
- valueButton:SetAllPoints(textLabel)
- editBox:SetText(textLabel:GetText())
- textLabel:Hide()
- editBox:Show()
- editBox:SetFocus()
- editBox:HighlightText()
- end)
-end
-
-local function configureSlider(slider, textLabel, minValue, maxValue, step, editBoxWidth, onValueChanged, rescale)
- slider._ecmMinValue = minValue
- slider._ecmMaxValue = maxValue
- slider._ecmStep = step
- slider._ecmRescale = rescale or nil
-
- if slider.MinText then
- slider.MinText:Hide()
- end
- if slider.MaxText then
- slider.MaxText:Hide()
- end
- if slider.RightText then
- slider.RightText:Hide()
- end
-
- if slider.Init then
- slider:Init(minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createSliderFormatters())
- if slider.Slider and slider.Slider.SetValueStep then
- slider.Slider:SetValueStep(step)
- end
- else
- slider:SetMinMaxValues(minValue, maxValue)
- slider:SetValueStep(step)
- slider:SetObeyStepOnDrag(true)
- end
-
- attachSliderValueEditor(slider, textLabel, editBoxWidth)
-
- local function handleValueChanged(_, value)
- local rounded = roundSliderValue(value, slider._ecmStep, slider._ecmMinValue, slider._ecmMaxValue)
- textLabel:SetText(tostring(roundToStep(rounded)))
- onValueChanged(rounded)
- end
-
- if slider.RegisterCallback and MinimalSliderWithSteppersMixin and MinimalSliderWithSteppersMixin.Event then
- slider:RegisterCallback(MinimalSliderWithSteppersMixin.Event.OnValueChanged, handleValueChanged, slider)
- else
- slider:HookScript("OnValueChanged", handleValueChanged)
- end
-end
-
-local function createTickRowWidgets(rowFrame, SB)
- rowFrame:SetHeight(34)
-
- local highlight = rowFrame:CreateTexture(nil, "BACKGROUND")
- highlight:SetAllPoints()
- highlight:SetColorTexture(1, 1, 1, 0.08)
- highlight:Hide()
- rowFrame._highlight = highlight
-
- rowFrame:EnableMouse(true)
- rowFrame:SetScript("OnEnter", function(self)
- self._highlight:Show()
- end)
- rowFrame:SetScript("OnLeave", function(self)
- self._highlight:Hide()
- end)
-
- local label = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- label:SetPoint("LEFT", 10, 0)
- label:SetWidth(70)
- label:SetJustifyH("LEFT")
- rowFrame._label = label
-
- local valueSlider = CreateFrame("Slider", nil, rowFrame, "MinimalSliderWithSteppersTemplate")
- valueSlider:SetPoint("LEFT", label, "RIGHT", 8, 0)
- valueSlider:SetWidth(150)
- rowFrame._valueSlider = valueSlider
-
- local valueText = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- valueText:SetPoint("LEFT", valueSlider, "RIGHT", 6, 0)
- valueText:SetWidth(50)
- valueText:SetJustifyH("LEFT")
- rowFrame._valueText = valueText
-
- local widthSlider = CreateFrame("Slider", nil, rowFrame, "MinimalSliderWithSteppersTemplate")
- widthSlider:SetPoint("LEFT", valueText, "RIGHT", 12, 0)
- widthSlider:SetWidth(90)
- rowFrame._widthSlider = widthSlider
-
- local widthText = rowFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- widthText:SetPoint("LEFT", widthSlider, "RIGHT", 6, 0)
- widthText:SetWidth(18)
- widthText:SetJustifyH("LEFT")
- rowFrame._widthText = widthText
-
- local swatch = SB.CreateColorSwatch(rowFrame)
- swatch:SetPoint("LEFT", widthText, "RIGHT", 10, 0)
- rowFrame._swatch = swatch
-
- local removeBtn = CreateFrame("Button", nil, rowFrame, "UIPanelButtonTemplate")
- removeBtn:SetSize(70, 22)
- removeBtn:SetPoint("LEFT", swatch, "RIGHT", 8, 0)
- removeBtn:SetText(L["REMOVE"])
- rowFrame._removeBtn = removeBtn
+function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
+ local categoryName = "Tick Marks"
+ local category
- local function rescaleValueSlider(targetValue)
- local newMax, newStep = getValueSliderRange(math.max(1, targetValue))
- if newMax ~= valueSlider._ecmMaxValue then
- valueSlider._ecmMaxValue = newMax
- valueSlider._ecmStep = newStep
- if valueSlider.Init then
- valueSlider:Init(targetValue, 1, newMax, getSliderStepCount(1, newMax, newStep), createSliderFormatters())
- if valueSlider.Slider and valueSlider.Slider.SetValueStep then
- valueSlider.Slider:SetValueStep(newStep)
- end
- end
+ local function refreshCategory()
+ if category then
+ SB.RefreshCategory(category)
+ else
+ SB.RefreshCategory(categoryName)
end
end
- configureSlider(valueSlider, valueText, 1, 200, 1, 60, function(rounded)
- if rowFrame._isRefreshing then
- return
- end
- store.UpdateTick(rowFrame._rowIndex, "value", rounded)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end, rescaleValueSlider)
-
- configureSlider(widthSlider, widthText, 1, 5, 1, 34, function(rounded)
- if rowFrame._isRefreshing then
- return
- end
- store.UpdateTick(rowFrame._rowIndex, "width", rounded)
+ local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end)
-end
-
-local function createTickMarksCanvas(SB, subcatName, parentCategory)
- local layout = SB.CreateCanvasLayout(subcatName, parentCategory)
- local frame = layout.frame
+ end
local function clearAllTicks()
store.SetCurrentTicks({})
- frame:RefreshTicks()
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ scheduleUpdate()
+ refreshCategory()
end
- local headerRow = layout:AddHeader(subcatName)
- local defaultsBtn = headerRow._defaultsButton
- defaultsBtn:SetText(SETTINGS_DEFAULTS)
- defaultsBtn:SetScript("OnClick", function()
- StaticPopup_Show("ECM_CONFIRM_CLEAR_TICKS", nil, nil, {
- onAccept = clearAllTicks,
- })
- end)
-
- layout:AddSpacer(2)
-
- layout:AddDescription(L["TICK_MARKS_DESC"], "GameFontHighlight")._text:SetWordWrap(true)
-
- local infoRow = layout:AddDescription("")
- local infoText = infoRow._text
- infoText:SetWordWrap(true)
-
- local _, defaultColorSwatch = layout:AddColorSwatch(L["DEFAULT_COLOR"])
- defaultColorSwatch:SetScript("OnClick", function()
- local c = store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
- ns.OptionUtil.OpenColorPicker(c, true, function(color)
- store.SetDefaultColor(color)
- defaultColorSwatch:SetColorRGB(color.r, color.g, color.b)
- end)
- end)
-
- local _, defaultWidthSlider, defaultWidthText = layout:AddSlider(L["DEFAULT_WIDTH"], 1, 5, 1)
- configureSlider(defaultWidthSlider, defaultWidthText, 1, 5, 1, 44, function(rounded)
- store.SetDefaultWidth(rounded)
- end)
-
- local _, addBtn = layout:AddButton(L["ADD_TICK_MARK"], L["ADD"])
- addBtn:SetScript("OnClick", function()
- store.AddTick(50, nil, nil)
- frame:RefreshTicks()
- end)
-
- local scrollBox, _, view = layout:AddScrollList(C.SCROLL_ROW_HEIGHT_WITH_CONTROLS)
-
- view:SetElementInitializer("Frame", function(rowFrame, data)
- if not rowFrame._initialized then
- createTickRowWidgets(rowFrame, SB)
- rowFrame._initialized = true
- end
-
- local index = data.index
- rowFrame._rowIndex = index
- rowFrame._highlight:Hide()
- rowFrame._label:SetText(string.format(L["TICK_N"], index))
-
- local tickValue = data.tick.value or 50
- local tickWidth = data.tick.width or store.GetDefaultWidth()
-
- rowFrame._isRefreshing = true
- if rowFrame._valueSlider._ecmRescale then
- rowFrame._valueSlider._ecmRescale(tickValue)
- end
- rowFrame._valueSlider:SetValue(tickValue)
- rowFrame._valueText:SetText(tostring(roundToStep(tickValue)))
- rowFrame._widthSlider:SetValue(tickWidth)
- rowFrame._widthText:SetText(tostring(roundToStep(tickWidth)))
- rowFrame._isRefreshing = false
-
- local tc = data.tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
- rowFrame._swatch:SetColorRGB(tc.r, tc.g, tc.b)
-
- rowFrame._swatch:SetScript("OnClick", function()
- local current = data.tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
- ns.OptionUtil.OpenColorPicker(current, true, function(color)
- store.UpdateTick(rowFrame._rowIndex, "color", color)
- rowFrame._swatch:SetColorRGB(color.r, color.g, color.b)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end)
- end)
-
- rowFrame._removeBtn:SetScript("OnClick", function()
- store.RemoveTick(rowFrame._rowIndex)
- frame:RefreshTicks()
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end)
- end)
-
- local dataProvider = CreateDataProvider()
- scrollBox:SetDataProvider(dataProvider)
-
- function frame:RefreshTicks()
- local ticks = store.GetCurrentTicks()
-
- dataProvider:Flush()
- for i, tick in ipairs(ticks) do
- dataProvider:Insert({ index = i, tick = tick })
- end
-
+ local function getTickSummary()
local _, _, localisedClassName, specName, className = ns.OptionUtil.GetCurrentClassSpec()
local color = C.CLASS_COLORS[className] or C.COLOR_WHITE_HEX
local classSpecLabel = "|cff" .. color .. (localisedClassName or "Unknown") .. "|r " .. (specName or "Unknown")
+ local ticks = store.GetCurrentTicks()
local count = #ticks
if count == 0 then
- infoText:SetText(string.format(L["NO_TICK_MARKS"], classSpecLabel))
- else
- infoText:SetText(string.format(L["TICK_COUNT"], classSpecLabel, count))
+ return string.format(L["NO_TICK_MARKS"], classSpecLabel)
end
-
- defaultsBtn:SetEnabled(count > 0)
-
- local dc = store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
- defaultColorSwatch:SetColorRGB(dc.r, dc.g, dc.b)
-
- local dw = store.GetDefaultWidth() or 1
- defaultWidthSlider:SetValue(dw)
- defaultWidthText:SetText(tostring(roundToStep(dw)))
+ return string.format(L["TICK_COUNT"], classSpecLabel, count)
end
- frame.OnDefault = clearAllTicks
+ local function buildTickCollectionItems()
+ local ticks = store.GetCurrentTicks()
+ local items = {}
+
+ for index, tick in ipairs(ticks) do
+ items[#items + 1] = {
+ label = string.format(L["TICK_N"], index),
+ fields = {
+ {
+ value = tick.value or 50,
+ min = 1,
+ max = 200,
+ step = 1,
+ sliderWidth = 150,
+ valueWidth = 50,
+ editWidth = 60,
+ getRange = function(_, targetValue)
+ local ceiling, step = getValueSliderRange(math.max(1, targetValue or tick.value or 50))
+ return 1, ceiling, step
+ end,
+ onValueChanged = function(rounded)
+ store.UpdateTick(index, "value", rounded)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ },
+ {
+ value = tick.width or store.GetDefaultWidth(),
+ min = 1,
+ max = 5,
+ step = 1,
+ sliderWidth = 90,
+ valueWidth = 18,
+ editWidth = 34,
+ onValueChanged = function(rounded)
+ store.UpdateTick(index, "width", rounded)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ },
+ },
+ color = {
+ value = tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR,
+ onClick = function()
+ local current = tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
+ ns.OptionUtil.OpenColorPicker(current, true, function(color)
+ store.UpdateTick(index, "color", color)
+ scheduleUpdate()
+ refreshCategory()
+ end)
+ end,
+ },
+ remove = {
+ text = L["REMOVE"],
+ onClick = function()
+ store.RemoveTick(index)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ },
+ }
+ end
- frame:SetScript("OnShow", function(self)
- self:RefreshTicks()
- end)
-end
+ return items
+ end
-function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
- createTickMarksCanvas(SB, "Tick Marks", parentCategory)
+ SB.RegisterFromTable({
+ name = categoryName,
+ parentCategory = parentCategory,
+ args = {
+ tickMarksHeader = {
+ type = "header",
+ name = categoryName,
+ actions = {
+ {
+ text = SETTINGS_DEFAULTS,
+ width = 100,
+ enabled = function()
+ return #store.GetCurrentTicks() > 0
+ end,
+ onClick = function()
+ StaticPopup_Show("ECM_CONFIRM_CLEAR_TICKS", nil, nil, {
+ onAccept = clearAllTicks,
+ })
+ end,
+ },
+ },
+ order = 1,
+ },
+ description = {
+ type = "info",
+ name = "",
+ value = L["TICK_MARKS_DESC"],
+ wide = true,
+ multiline = true,
+ height = 36,
+ order = 2,
+ },
+ summary = {
+ type = "info",
+ name = "",
+ value = getTickSummary,
+ wide = true,
+ multiline = true,
+ height = 28,
+ order = 3,
+ },
+ defaultColor = {
+ type = "color",
+ key = "tickMarksDefaultColor",
+ name = L["DEFAULT_COLOR"],
+ default = C.DEFAULT_POWERBAR_TICK_COLOR,
+ get = function()
+ return store.GetDefaultColor()
+ end,
+ set = function(color)
+ store.SetDefaultColor(color)
+ end,
+ onSet = function()
+ refreshCategory()
+ end,
+ order = 4,
+ },
+ defaultWidth = {
+ type = "range",
+ key = "tickMarksDefaultWidth",
+ name = L["DEFAULT_WIDTH"],
+ default = 1,
+ min = 1,
+ max = 5,
+ step = 1,
+ get = function()
+ return store.GetDefaultWidth()
+ end,
+ set = function(width)
+ store.SetDefaultWidth(width)
+ end,
+ onSet = function()
+ refreshCategory()
+ end,
+ order = 5,
+ },
+ addTick = {
+ type = "button",
+ name = L["ADD_TICK_MARK"],
+ buttonText = L["ADD"],
+ onClick = function()
+ store.AddTick(50, nil, nil)
+ scheduleUpdate()
+ refreshCategory()
+ end,
+ order = 6,
+ },
+ tickCollection = {
+ type = "collection",
+ preset = "editor",
+ height = 320,
+ rowHeight = C.SCROLL_ROW_HEIGHT_WITH_CONTROLS,
+ items = buildTickCollectionItems,
+ order = 7,
+ },
+ },
+ })
+
+ category = SB.GetSubcategory(categoryName)
end
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index c633d3b8..91ec6a6d 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -68,8 +68,8 @@ local function getPreferredProfileSelection(valuesGenerator)
return first or ""
end
---- Creates a proxy-backed dropdown for transient profile selection (not stored in SavedVars).
-local function createProfilePicker(cat, variable, name, tooltip, valuesGenerator)
+--- Creates a handler-backed dropdown for transient profile selection (not stored in SavedVars).
+local function createProfilePicker(SB, cat, variable, name, tooltip, valuesGenerator)
local selected = getPreferredProfileSelection(valuesGenerator)
local function ensureSelection()
@@ -78,14 +78,32 @@ local function createProfilePicker(cat, variable, name, tooltip, valuesGenerator
end
end
- local setting = Settings.RegisterProxySetting(cat, variable, Settings.VarType.String, name, selected, function()
- ensureSelection()
- return selected
- end, function(value)
- selected = value
- end)
- Settings.CreateDropdown(cat, setting, valuesGenerator, tooltip)
+ local function values()
+ local map = {}
+ for _, entry in ipairs(valuesGenerator()) do
+ map[entry.value] = entry.label
+ end
+ return map
+ end
+
+ local _, setting = SB.Dropdown({
+ category = cat,
+ key = variable,
+ name = name,
+ tooltip = tooltip,
+ default = selected,
+ scrollHeight = 240,
+ values = values,
+ get = function()
+ ensureSelection()
+ return selected
+ end,
+ set = function(value)
+ selected = value
+ end,
+ })
ensureSelection()
+
return setting, function()
ensureSelection()
return selected
@@ -96,31 +114,35 @@ end
function ProfileOptions.RegisterSettings(SB)
local cat = SB.CreateSubcategory(L["PROFILES"])
+ local function refreshCategory()
+ SB.RefreshCategory(cat)
+ end
-- Switch Profile
SB.Header(L["ACTIVE_PROFILE"])
- local switchSetting = Settings.RegisterProxySetting(
- cat,
- "ECM_ProfileSwitch",
- Settings.VarType.String,
- L["SWITCH_PROFILE"],
- ns.Addon.db:GetCurrentProfile(),
- function()
+ local _, switchSetting = SB.Dropdown({
+ category = cat,
+ key = "ECM_ProfileSwitch",
+ name = L["SWITCH_PROFILE"],
+ tooltip = L["SWITCH_PROFILE_DESC"],
+ default = ns.Addon.db:GetCurrentProfile(),
+ scrollHeight = 240,
+ values = function()
+ local values = {}
+ for _, name in ipairs(ns.Addon.db:GetProfiles()) do
+ values[name] = name
+ end
+ return values
+ end,
+ get = function()
return ns.Addon.db:GetCurrentProfile()
end,
- function(value)
+ set = function(value)
ns.Addon.db:SetProfile(value)
- end
- )
-
- Settings.CreateDropdown(cat, switchSetting, function()
- local container = Settings.CreateControlTextContainer()
- for _, name in ipairs(ns.Addon.db:GetProfiles()) do
- container:Add(name, name)
- end
- return container:GetData()
- end, L["SWITCH_PROFILE_DESC"])
+ refreshCategory()
+ end,
+ })
SB.Button({
name = L["NEW_PROFILE"],
@@ -130,6 +152,7 @@ function ProfileOptions.RegisterSettings(SB)
StaticPopup_Show("ECM_NEW_PROFILE", nil, nil, {
onAccept = function(name)
switchSetting:SetValue(name)
+ refreshCategory()
end,
})
end,
@@ -150,7 +173,7 @@ function ProfileOptions.RegisterSettings(SB)
end
local _, getCopyProfile, clearCopyProfile =
- createProfilePicker(cat, "ECM_ProfileCopy", L["COPY_FROM"], L["COPY_FROM_DESC"], otherProfilesGenerator)
+ createProfilePicker(SB, cat, "ECM_ProfileCopy", L["COPY_FROM"], L["COPY_FROM_DESC"], otherProfilesGenerator)
SB.Button({
name = L["COPY"],
@@ -165,12 +188,14 @@ function ProfileOptions.RegisterSettings(SB)
onAccept = function()
ns.Addon.db:CopyProfile(profile)
clearCopyProfile()
+ refreshCategory()
end,
})
end,
})
local _, getDeleteProfile, clearDeleteProfile = createProfilePicker(
+ SB,
cat,
"ECM_ProfileDelete",
L["DELETE_PROFILE"],
@@ -191,6 +216,7 @@ function ProfileOptions.RegisterSettings(SB)
onAccept = function()
ns.Addon.db:DeleteProfile(profile)
clearDeleteProfile()
+ refreshCategory()
end,
})
end,
From c3d7d54559db59236c4ef1e51567c834452c8d34 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:38:25 +1000
Subject: [PATCH 08/53] update serena memories
---
.../extraicons/options-dsl-and-migration.md | 28 ++++++++
.serena/memories/project_overview.md | 56 ++++++---------
.serena/memories/style_and_conventions.md | 71 +++++++++----------
.serena/memories/task_completion.md | 33 ++++-----
4 files changed, 101 insertions(+), 87 deletions(-)
create mode 100644 .serena/memories/extraicons/options-dsl-and-migration.md
diff --git a/.serena/memories/extraicons/options-dsl-and-migration.md b/.serena/memories/extraicons/options-dsl-and-migration.md
new file mode 100644
index 00000000..65dff0fc
--- /dev/null
+++ b/.serena/memories/extraicons/options-dsl-and-migration.md
@@ -0,0 +1,28 @@
+# ExtraIcons / Options DSL / SpellColors refresh
+
+## ExtraIcons replaced ItemIcons
+- `Modules/ItemIcons.lua` and `UI/ItemIconsOptions.lua` were removed and replaced by `Modules/ExtraIcons.lua` and `UI/ExtraIconsOptions.lua`.
+- The profile model changed from boolean flags under `profile.itemIcons` to `profile.extraIcons = { enabled, viewers = { utility = {...}, main = {...} } }`.
+- `Constants.EXTRAICONS` maps to config key `extraIcons`; `Constants.MODULE_ORDER` now includes `ExtraIcons`.
+- Defaults seed `extraIcons.viewers.utility` with builtin stack entries for `trinket1`, `trinket2`, `combatPotions`, `healthPotions`, and `healthstones`.
+- Migration V12 is the frozen conversion from legacy `itemIcons` flags into ordered `extraIcons.viewers.utility` entries and then deletes `profile.itemIcons`.
+
+## Runtime coupling
+- `ExtraIcons` is not treated like a purely independent trailing module. `Runtime.updateAllLayouts()` runs `ExtraIcons:UpdateLayout()` before the chained bar modules so width-sensitive bars can anchor against the final widened main-viewer footprint.
+- `ExtraIcons:GetMainViewerAnchor()` returns a synthetic anchor frame when the module extends the main Blizzard viewer; otherwise it falls back to the Blizzard frame.
+- `ExtraIcons` manages two viewers (`utility`, `main`), tracks icon pools per viewer, hooks viewer/show/size events, and refreshes cooldowns from bag/equipment/spell events.
+
+## ExtraIcons options UI
+- `UI/ExtraIconsOptions.lua` is fully data-driven through `LibSettingsBuilder`.
+- The central `RegisterSettings(SB)` builds a sectioned `collection` with one section per viewer, each containing action rows (reorder, move between viewers, hide/show builtin row, delete custom row).
+- The add-entry flow uses draft state per viewer, a mode-input trailer, duplicate detection, and async item-name refresh via `GET_ITEM_INFO_RECEIVED` + `SB.RefreshCategory(...)`.
+- Builtin rows and the current racial ability are represented as special rows instead of separate bespoke controls.
+
+## LibSettingsBuilder expansion
+- `Libs/LibSettingsBuilder/LibSettingsBuilder.lua` now supports richer row types and behaviors used across ECM options: `header`, `subheader`, `info`, `input`, `button`, `collection`, embedded canvas rows, sectioned collections, swatch/editor presets, action buttons, and inline slider editing.
+- Prefer extending the existing DSL-driven options flow instead of hand-building new settings-frame widgets when working on complex options screens.
+
+## SpellColors / BuffBars options
+- `SpellColors.lua` now owns the keyed buff-bar color model. Persisted entries are class/spec-scoped and keyed by name/spellID/cooldownID/texture tiers.
+- `SpellColors.GetAllColorEntries()` merges persisted rows with runtime-discovered keys so `UI/BuffBarsOptions.lua` can render active bars without poking into `BuffBars` internals.
+- Buff-bar spell colors UI now uses a `collection` with swatch rows plus header actions for reset, reconcile/reload, and stale-entry cleanup.
diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md
index 1ce7349a..fe97bf71 100644
--- a/.serena/memories/project_overview.md
+++ b/.serena/memories/project_overview.md
@@ -1,39 +1,27 @@
-# Enhanced Cooldown Manager - Project Overview
+# Enhanced Cooldown Manager - Current Project Overview
## Purpose
-A World of Warcraft retail addon that creates a clean combat HUD around Blizzard's built-in Cooldown Manager. Provides inline resource bars (power, class resources, runes, aura/buff bars) and item icon cooldowns anchored to the native UI.
+EnhancedCooldownManager is a WoW retail addon that extends Blizzard's built-in Cooldown Manager with chained resource bars, aura/buff bars, and configurable extra icon viewers anchored to the native HUD.
-## Tech Stack
-- **Language**: Lua 5.1 (WoW embedded runtime)
-- **Libraries**: AceAddon-3.0, LibEvent-1.0, AceConsole-3.0, AceDB-3.0, LibSharedMedia-3.0, LibSerialize, LibDeflate, LibEQOL, LibSettingsBuilder
-- **Testing**: Busted (Lua test framework)
-- **Linting**: luacheck
-- **License**: GNU GPLv3, Author: Argium
+## Current Top-Level Structure
+- `Constants.lua`: authoritative constants, module identifiers, schema version, shared lookup tables.
+- `Defaults.lua`: default profile data, including `extraIcons.viewers` defaults and power-bar tick mappings.
+- `ECM.lua`: AceAddon entry point, profile lifecycle, slash commands, high-level integration.
+- `Runtime.lua`: central event/layout dispatcher, fade/hidden enforcement, deferred layout scheduling, module enable/disable.
+- `Migration.lua`: frozen saved-variable migrations; current schema version is `12`.
+- `SpellColors.lua`: class/spec-scoped buff-bar color store plus runtime-discovered keys exposed to the UI.
+- `Modules/`: `PowerBar`, `ResourceBar`, `RuneBar`, `BuffBars`, `ExtraIcons`.
+- `UI/`: options sections registered via `ns.OptionsSections[key].RegisterSettings(SB)`.
+- `Tests/`: busted suite covering runtime, migrations, modules, and options UI.
-## Codebase Structure
-```
-ECM_Constants.lua -- All constants (MANDATORY location)
-ECM_Defaults.lua -- Default configuration values
-ECM.lua -- Main addon entry point (AceAddon)
-Helpers/ -- Shared utilities and mixins (ModuleMixin, FrameMixin, BarMixin, etc.)
-Modules/ -- Feature modules (PowerBar, ResourceBar, RuneBar, BuffBars, ItemIcons)
-UI/ -- Settings/options panels
-Libs/ -- Third-party libraries (do not edit)
-Tests/ -- Busted test suite with stubs/ for WoW API mocks
-Media/ -- Fonts, textures
-```
+## Architecture Notes
+- `Runtime.lua` is the single layout pipeline. It handles WoW events, coalesced layout requests, delayed layout updates, global hidden/alpha state, Blizzard frame enforcement, and module iteration.
+- `Constants.CHAIN_ORDER` is `PowerBar -> ResourceBar -> RuneBar -> BuffBars`. `Constants.MODULE_ORDER` adds `ExtraIcons`.
+- `ExtraIcons` is updated once before chained modules because its main viewer can widen the effective anchor footprint used by downstream width-sensitive layouts.
+- Options UIs are now largely driven by `LibSettingsBuilder`'s table/DSL API instead of bespoke settings frame code.
+- Buff-bar spell colors are no longer just a simple per-bar cache; they are keyed stores merged with runtime-discovered entries so the UI can operate without reaching into `BuffBars` internals.
-## Architecture
-- AceAddon-3.0 based with AceDB-3.0 for saved variables
-- Modules use `ModuleMixin` for config: `self:GetGlobalConfig()`, `self:GetModuleConfig()`
-- `FrameMixin` for frame lifecycle; `BarMixin` for bar rendering
-- Loose coupling via events for inter-module communication
-- Private methods/fields prefixed with underscore (_)
-- Global table `ECM` for cross-file constants and mixins
-- Load order: Constants -> Defaults -> Helpers -> ECM.lua -> Modules -> UI
-
-## Secret Values (WoW Taint System)
-- `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, `C_UnitAuras.GetUnitAuraBySpellID` return secret values
-- Cannot compare, test, or use as table keys
-- Use `CurveConstants.ScaleTo100` for adjusted values
-- NEVER nil check or wrap `issecretvalue()` / `issecrettable()` built-ins
+## Persistence / Migration Highlights
+- Schema changes through V12 include spell-color store normalization and the `itemIcons` -> `extraIcons.viewers` migration.
+- Edit-mode position migration seeds per-layout positions from the old single-position model.
+- Migration snapshots are intentionally frozen and should not depend on live production helpers.
diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md
index 0018784b..84a95227 100644
--- a/.serena/memories/style_and_conventions.md
+++ b/.serena/memories/style_and_conventions.md
@@ -1,43 +1,40 @@
-# Code Style and Conventions
+# Current Style and Conventions
-## Copyright Header (MANDATORY on all .lua files)
-```lua
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-```
+## Mandatory Repo Rules
+- Every Lua file keeps the standard GPL copyright header.
+- Keep `ARCHITECTURE.md` in sync with real architecture changes.
+- Target WoW Lua 5.1 only; do not introduce post-5.1 features.
-## Naming
-- Constants: UPPER_SNAKE_CASE (stored in ECM_Constants.lua, accessed via `ECM.Constants`)
-- Functions/methods: PascalCase (e.g., `GetGlobalConfig`, `OnEnable`)
-- Private methods/fields: prefixed with underscore (e.g., `_configKey`, `_updateBar`)
-- Local variables: camelCase
-- Module names: PascalCase (e.g., `PowerBar`, `BuffBars`)
+## State and Structure
+- Mutable state belongs on the owning instance as `self._field`, not file-level locals.
+- Prefix private methods/fields with `_`.
+- Do not use forward declarations.
+- Alias shared modules once at file scope when reused.
+- Keep constants in `Constants.lua`; keep defaults in `Defaults.lua`.
-## Type Annotations
-- Use LuaCATS `@class`, `@field`, `@param`, `@return` annotations
-- Place `@class` annotations at top of file after copyright header
-- Group related `@field` annotations within each class
-- Add descriptions: getters start with "Gets ...", setters with "Sets ..."
+## Runtime / Performance
+- Never use `OnUpdate` or frame-rate tickers for feature logic; prefer event-driven work plus a single deferred timer when needed.
+- Reuse tables on hot paths with `wipe()`.
+- Cancel superseded timers before scheduling replacement deferred work.
+- Guard hot-path debug logs with `if ECM.IsDebugEnabled() then`.
+- Periodic setup work must stop once setup is complete.
+- Defer once out of restricted contexts; avoid stacked `C_Timer.After(0)` chains.
-## Architecture Rules
-- **ALL constants** must be in ECM_Constants.lua
-- Modules using ModuleMixin must use `self:GetGlobalConfig()` and `self:GetModuleConfig()` -- never `mod.db` or `mod.db.profile` directly
-- NEVER create intermediate tables for profile/config
-- NEVER listen to `OnUpdate` event
-- No forward declarations
-- Prefer loose coupling: events, hooks, callbacks for inter-module communication
-- Use assertions liberally to catch error states
+## Architecture Boundaries
+- Prefer loose coupling via events, hooks, callbacks, or messages.
+- Maintain one source of truth for shared state / derived values.
+- Do not add trivial passthrough wrappers or duplicate helpers.
+- Remove dead code, stale fields, unused locale strings, and impossible branches.
+- Shared confirm dialogs should use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`.
+- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code.
-## Testing
-- New features/regression fixes in `/Bars`, `/Modules`, `/UI`, and `ECM.lua` MUST include test cases
-- Tests use Busted framework with WoW API stubs in Tests/stubs/
-- Test files named `*_spec.lua`
+## Options / UI Patterns
+- Current options pages prefer the `LibSettingsBuilder` DSL/table registration flow.
+- Complex pages use collection-style rows (`header`, `subheader`, `info`, `button`, `collection`, `input`) and refresh through `SB.RefreshCategory(...)`.
+- Dynamic option UIs should keep logic in the owning options module rather than reaching into unrelated modules.
-## Code Review Standards
-- No unused variables
-- No unnecessary assignments, guards, functions, boilerplate
-- Comments for complex sections (but not redundant comments restating function names)
-- No code duplication
-- Minimal complexity; simplicity is paramount
-- Remove dead code, trivial wrappers, dead type checking
+## Tests / Secrets
+- Be skeptical about changing tests to satisfy failures.
+- Test load order should mirror TOC load order.
+- Reuse `Tests/TestHelpers.lua` before inventing new shared helpers.
+- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values; only pass them to APIs/built-ins that accept secrets.
diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md
index e427d8c1..a1cb81d4 100644
--- a/.serena/memories/task_completion.md
+++ b/.serena/memories/task_completion.md
@@ -1,19 +1,20 @@
# Task Completion Checklist
-When completing a task, perform these steps:
+## Validation Commands
+- Addon tests: `busted Tests`
+- Library tests: `busted --run libsettingsbuilder`, `busted --run libconsole`, `busted --run libevent`, `busted --run liblsmsettingswidgets`
+- Lint: `luacheck . -q`
-1. **Verify constants**: Any new constants must be in `ECM_Constants.lua`
-2. **Copyright header**: All new/modified .lua files must have the standard header
-3. **Run tests**: `busted Tests`
-4. **Run linter**: `luacheck . -q`
-5. **Code review** (for anything beyond a small targeted fix):
- - Check for unused variables
- - Check for unnecessary assignments, guards, boilerplate
- - Verify no code duplication
- - Ensure complex sections have comments
- - Verify test coverage for changes in Modules/, UI/, ECM.lua
- - Ensure loose coupling between components
- - Remove dead code and trivial wrappers
-6. **Config access**: Modules using ModuleMixin use `self:GetGlobalConfig()` / `self:GetModuleConfig()` only
-7. **No OnUpdate**: Never use the OnUpdate event
-8. **No forward declarations**
+## When To Run Validation
+- Treat validation as a pre-commit step, not an every-iteration step.
+- Do not run the full test/lint suites while iterating unless they are needed to debug a specific issue.
+- Before committing a change, updates to `Modules/`, `Helpers/`, `UI/`, and `ECM*.lua` must pass `busted Tests` and `luacheck . -q`.
+- Before committing a library change, also run that library's dedicated test suite.
+
+## Completion Checks
+- Verify new constants live in `Constants.lua`.
+- Keep the copyright header intact on all modified/new Lua files.
+- Keep `ARCHITECTURE.md` current when architecture changes.
+- Review for duplication, dead code, redundant guards/assignments, and avoidable allocations.
+- Preserve loose coupling and single-source-of-truth ownership.
+- Do not introduce `OnUpdate` loops or forward declarations.
From 1f5ace9226aeba78718c04d964118f4a4d7ff184 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:38:54 +1000
Subject: [PATCH 09/53] Update refreshModeInputRow
---
.../LibSettingsBuilder/LibSettingsBuilder.lua | 79 +++++++++++----
.../Tests/LibSettingsBuilder_spec.lua | 98 +++++++++++++++++++
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 1 +
3 files changed, 158 insertions(+), 20 deletions(-)
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index 3b85c1ea..b7adc89a 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -225,8 +225,21 @@ local function showFrame(frame)
end
end
+local function getTooltipTitleColorComponents()
+ local color = TOOLTIP_TITLE_COLOR
+ if color and color.GetRGBA then
+ return color:GetRGBA()
+ end
+
+ return color and color.r or 1,
+ color and color.g or 1,
+ color and color.b or 1,
+ color and color.a or 1
+end
+
local function setGameTooltipText(text, wrap)
- GameTooltip:SetText(text, TOOLTIP_TITLE_COLOR, 1, wrap == true)
+ local r, g, b, a = getTooltipTitleColorComponents()
+ GameTooltip:SetText(text, r, g, b, a, wrap == true)
end
local function setSimpleTooltip(owner, text)
@@ -1544,6 +1557,9 @@ local function ensureModeInputRow(row)
if trailer and trailer.onTextChanged then
trailer.onTextChanged(self:GetText() or "", trailer, row, row._lsbSectionData)
end
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
end)
row._editBox:SetScript("OnEnterPressed", function()
local trailer = row._lsbTrailerData
@@ -1557,12 +1573,16 @@ local function ensureModeInputRow(row)
end)
row._editBox:SetScript("OnTabPressed", function()
local trailer = row._lsbTrailerData
+ local keepFocus = nil
if trailer and trailer.onTabPressed then
- local keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData)
- if keepFocus then
- row._editBox:SetFocus()
- row._editBox:HighlightText()
- end
+ keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData)
+ end
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ if keepFocus then
+ row._editBox:SetFocus()
+ row._editBox:HighlightText()
end
end)
row._editBox:SetScript("OnEscapePressed", function(self)
@@ -1580,6 +1600,10 @@ local function ensureModeInputRow(row)
end)
end
+local function getModeInputTrailerValue(trailer, key, row, sectionData)
+ return evaluateStaticOrFunction(trailer and trailer[key], trailer, row, sectionData)
+end
+
local function refreshModeInputRow(row, trailer, sectionData)
ensureModeInputRow(row)
@@ -1588,17 +1612,32 @@ local function refreshModeInputRow(row, trailer, sectionData)
row._lsbTrailerRefresh = function(activeRow)
local currentTrailer = activeRow._lsbTrailerData or {}
- local text = currentTrailer.inputText or ""
-
- activeRow._modeButton:SetText(currentTrailer.modeText or "")
- setSimpleTooltip(activeRow._modeButton, currentTrailer.modeTooltip)
+ local activeSectionData = activeRow._lsbSectionData
+ local disabled = getModeInputTrailerValue(currentTrailer, "disabled", activeRow, activeSectionData) == true
+ local modeEnabled = getModeInputTrailerValue(currentTrailer, "modeEnabled", activeRow, activeSectionData)
+ local inputEnabled = getModeInputTrailerValue(currentTrailer, "inputEnabled", activeRow, activeSectionData)
+ local submitEnabled = getModeInputTrailerValue(currentTrailer, "submitEnabled", activeRow, activeSectionData)
+ local modeText = getModeInputTrailerValue(currentTrailer, "modeText", activeRow, activeSectionData)
+ local modeTooltip = getModeInputTrailerValue(currentTrailer, "modeTooltip", activeRow, activeSectionData)
+ local text = getModeInputTrailerValue(currentTrailer, "inputText", activeRow, activeSectionData) or ""
+ local placeholder = getModeInputTrailerValue(currentTrailer, "placeholder", activeRow, activeSectionData)
+ local previewIcon = getModeInputTrailerValue(currentTrailer, "previewIcon", activeRow, activeSectionData)
+ local previewText = getModeInputTrailerValue(currentTrailer, "previewText", activeRow, activeSectionData)
+ local submitText = getModeInputTrailerValue(currentTrailer, "submitText", activeRow, activeSectionData)
+ local submitTooltip = getModeInputTrailerValue(currentTrailer, "submitTooltip", activeRow, activeSectionData)
+
+ activeRow._modeButton:SetText(modeText or "")
+ setSimpleTooltip(activeRow._modeButton, modeTooltip)
activeRow._modeButton:SetScript("OnClick", function()
if currentTrailer.onToggleMode then
currentTrailer.onToggleMode(currentTrailer, activeRow, activeRow._lsbSectionData)
end
+ if activeRow._lsbTrailerRefresh then
+ activeRow._lsbTrailerRefresh(activeRow)
+ end
end)
if activeRow._modeButton.SetEnabled then
- activeRow._modeButton:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.modeEnabled ~= false)
+ activeRow._modeButton:SetEnabled(not disabled and modeEnabled ~= false)
end
if activeRow._editBox.GetText and activeRow._editBox:GetText() ~= text then
@@ -1607,34 +1646,34 @@ local function refreshModeInputRow(row, trailer, sectionData)
activeRow._lsbSyncingText = nil
end
if activeRow._editBox.SetEnabled then
- activeRow._editBox:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.inputEnabled ~= false)
+ activeRow._editBox:SetEnabled(not disabled and inputEnabled ~= false)
end
- activeRow._placeholder:SetText(currentTrailer.placeholder or "")
+ activeRow._placeholder:SetText(placeholder or "")
if activeRow._lsbHasFocus or text ~= "" then
activeRow._placeholder:Hide()
else
activeRow._placeholder:Show()
end
- if currentTrailer.previewIcon then
- activeRow._previewIcon:SetTexture(currentTrailer.previewIcon)
+ if previewIcon then
+ activeRow._previewIcon:SetTexture(previewIcon)
activeRow._previewIcon:Show()
else
activeRow._previewIcon:SetTexture(nil)
activeRow._previewIcon:Hide()
end
- if currentTrailer.previewText and currentTrailer.previewText ~= "" then
- activeRow._previewLabel:SetText(currentTrailer.previewText)
+ if previewText and previewText ~= "" then
+ activeRow._previewLabel:SetText(previewText)
activeRow._previewLabel:Show()
else
activeRow._previewLabel:SetText("")
activeRow._previewLabel:Hide()
end
- activeRow._submitButton:SetText(currentTrailer.submitText or ADD or "Add")
- setSimpleTooltip(activeRow._submitButton, currentTrailer.submitTooltip)
+ activeRow._submitButton:SetText(submitText or ADD or "Add")
+ setSimpleTooltip(activeRow._submitButton, submitTooltip)
activeRow._submitButton:SetScript("OnClick", function()
if currentTrailer.onSubmit then
local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData)
@@ -1645,7 +1684,7 @@ local function refreshModeInputRow(row, trailer, sectionData)
end
end)
if activeRow._submitButton.SetEnabled then
- activeRow._submitButton:SetEnabled(currentTrailer.disabled ~= true and currentTrailer.submitEnabled ~= false)
+ activeRow._submitButton:SetEnabled(not disabled and submitEnabled ~= false)
end
end
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
index b8a0a1cb..ce2f9b1b 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
@@ -2515,6 +2515,104 @@ describe("LibSettingsBuilder", function()
_G.CreateScrollBoxListLinearView = originalCreateView
_G.ScrollUtil = originalScrollUtil
end)
+
+ it("Collection modeInput trailers reevaluate dynamic fields in place while typing", function()
+ local originalCreateFrame = _G.CreateFrame
+ local state = {
+ kind = "spell",
+ idText = "",
+ }
+
+ _G.CreateFrame = function(...)
+ local frame = originalCreateFrame(...)
+ frame.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ return fontString
+ end
+ frame.CreateTexture = function()
+ local texture = createScriptableFrame()
+ texture.SetTexture = function(self, value)
+ self._texture = value
+ end
+ texture.GetTexture = function(self)
+ return self._texture
+ end
+ return texture
+ end
+ return frame
+ end
+
+ local ok, err = pcall(function()
+ local init = SB.Collection({
+ height = 120,
+ sections = function()
+ return {
+ {
+ key = "dynamic",
+ title = "Dynamic",
+ items = {},
+ trailer = {
+ preset = "modeInput",
+ modeText = function()
+ return state.kind == "spell" and "Spell" or "Item"
+ end,
+ inputText = function()
+ return state.idText
+ end,
+ placeholder = function()
+ return state.kind == "spell" and "Spell ID" or "Item ID"
+ end,
+ previewText = function()
+ return state.idText ~= "" and (state.kind .. ":" .. state.idText) or ""
+ end,
+ submitEnabled = function()
+ return state.idText ~= ""
+ end,
+ onTextChanged = function(text)
+ state.idText = text
+ end,
+ onTabPressed = function()
+ state.kind = state.kind == "spell" and "item" or "spell"
+ return true
+ end,
+ },
+ },
+ }
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+
+ local trailerRow = assert(frame._lsbSectionTrailerRows.dynamic)
+ local editBox = assert(trailerRow._editBox)
+ editBox:SetFocus()
+ trailerRow._lsbHasFocus = true
+ editBox:SetText("12345")
+ editBox:GetScript("OnTextChanged")(editBox)
+
+ assert.are.equal("12345", state.idText)
+ assert.are.equal("spell:12345", trailerRow._previewLabel:GetText())
+ assert.is_true(editBox._focused)
+
+ editBox:GetScript("OnTabPressed")()
+
+ assert.are.equal("Item", trailerRow._modeButton:GetText())
+ assert.are.equal("Item ID", trailerRow._placeholder:GetText())
+ assert.is_true(editBox._focused)
+ assert.is_true(editBox._highlighted)
+ end)
+ _G.CreateFrame = originalCreateFrame
+ if not ok then
+ error(err, 0)
+ end
+ end)
end)
describe("Custom control integration", function()
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index 7dcd67db..df3b24db 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -168,6 +168,7 @@ Supported collection row presets:
- `editor` — label plus one or more slider fields, optional swatch, and remove button
- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`)
- section trailers support `preset = "modeInput"` for toggle + input + preview + submit rows
+ Mode-input trailer display fields may be static values or functions that are re-evaluated during in-place row refreshes.
## Composite builders
From 4e7db8ce7714171a9cb60b4b5382725dbcbd2a69 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:40:39 +1000
Subject: [PATCH 10/53] Show the hud preview when on the extra options page.
---
Runtime.lua | 6 +++---
UI/ExtraIconsOptions.lua | 6 ++++++
2 files changed, 9 insertions(+), 3 deletions(-)
diff --git a/Runtime.lua b/Runtime.lua
index 70f8ae58..20505649 100644
--- a/Runtime.lua
+++ b/Runtime.lua
@@ -151,8 +151,8 @@ local function updateFadeAndHiddenStates()
return
end
- -- Force-show while edit mode or the Layout options preview is active so the
- -- user can see and position modules without hide/fade interference.
+ -- Force-show while edit mode or an options preview is active so the user
+ -- can see and position modules without hide/fade interference.
if LibEditMode:IsInEditMode() or _layoutPreviewActive then
setGloballyHidden(false)
setAlpha(1)
@@ -403,7 +403,7 @@ end
-- Public API
--------------------------------------------------------------------------------
---- Sets or clears the layout preview override.
+--- Sets or clears the options preview override.
--- When active, hide-when-mounted, hide-in-rest, and out-of-combat fade are bypassed.
---@param active boolean
function Runtime.SetLayoutPreview(active)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 231688c6..df7a5117 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -1061,6 +1061,12 @@ function ExtraIconsOptions.RegisterSettings(SB)
SB.RegisterFromTable({
name = categoryName,
path = "extraIcons",
+ onShow = function()
+ ns.Runtime.SetLayoutPreview(true)
+ end,
+ onHide = function()
+ ns.Runtime.SetLayoutPreview(false)
+ end,
args = {
enabled = {
type = "toggle",
From 73260e0c2a12bed99d76dd2c3a653cca41a50e39 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 13 Apr 2026 13:50:09 +1000
Subject: [PATCH 11/53] Checkpoint for settings cleanup
---
AGENTS.md | 6 +-
.../LibSettingsBuilder/LibSettingsBuilder.lua | 17 +---
Tests/TestHelpers.lua | 50 ++++++++--
Tests/UI/ExtraIconsOptions_spec.lua | 53 ++++++++--
UI/BuffBarsOptions.lua | 5 +-
UI/ExtraIconsOptions.lua | 97 +++++++++++--------
6 files changed, 150 insertions(+), 78 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 05c566cb..29558808 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,9 +55,12 @@ luacheck . -q
## Architecture and Boundaries
- Prefer loose coupling via events, hooks, callbacks, or messages.
+- Prefer the simplest production code that satisfies current supported runtime requirements. Do not add fallback paths, compatibility branches, or defensive adapters unless a concrete supported environment requires them.
- Maintain a single source of truth for shared state and derived values: derive once, store once, read everywhere.
- Do not duplicate utilities or add trivial passthrough wrappers; extend the canonical owner instead.
+- Before introducing a helper, wrapper, or abstraction, prove that it reduces real complexity for multiple callers or captures a distinct behavior that needs to be tested independently.
- Do not extract single-use helpers unless they have a clear independently testable contract or 2+ callers.
+- Do not add production-only indirection around fixed literal values or stable API signatures. If a call always uses the same simple value, pass it directly unless a shared abstraction has a real runtime need.
- Prefer constant lookup tables over pure mapping functions for small fixed domains.
- Remove dead code, stale fields, impossible branches, and unused locale strings.
- Clear critical state flags with `pcall` or equivalent so one error cannot wedge later work.
@@ -65,9 +68,10 @@ luacheck . -q
## Tests, Libraries, and Migrations
- Be skeptical about changing tests to satisfy failures; the failure may be real.
+- If tests diverge from WoW or library runtime behavior, fix the stub or fixture to match production instead of adding fallback paths or compatibility helpers to live code.
- Test load order must mirror TOC load order.
- Stub the canonical function, not a wrapper or alias.
-- Prefer testing live production code; avoid mirrored helper logic in specs.
+- Test production code directly. Do not mirror, duplicate, or reimplement production logic in specs.
- Reuse `Tests/TestHelpers.lua` before creating new shared test helpers.
- Test files mirror source paths; library tests stay under `Libs//Tests/`.
- `StaticPopup_Show` stubs must forward `(name, text1, text2, data)` and call `OnAccept(self, data)`.
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
index b7adc89a..b780ed0d 100644
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
@@ -18,8 +18,6 @@ lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
-local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
-
lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
@@ -225,21 +223,8 @@ local function showFrame(frame)
end
end
-local function getTooltipTitleColorComponents()
- local color = TOOLTIP_TITLE_COLOR
- if color and color.GetRGBA then
- return color:GetRGBA()
- end
-
- return color and color.r or 1,
- color and color.g or 1,
- color and color.b or 1,
- color and color.a or 1
-end
-
local function setGameTooltipText(text, wrap)
- local r, g, b, a = getTooltipTitleColorComponents()
- GameTooltip:SetText(text, r, g, b, a, wrap == true)
+ GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true)
end
local function setSimpleTooltip(owner, text)
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 98add12d..95425675 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -905,19 +905,53 @@ function TestHelpers.SetupGameTooltipStub()
self._titleWrap = nil
self._lines = {}
end,
- SetText = function(self, text, color, alpha, wrap, ...)
- if type(color) == "number" or type(wrap) == "number" or select("#", ...) > 0 then
- error("GameTooltip:SetText expects text, color, alpha, wrap", 2)
+ SetText = function(self, text, r, g, b, a, wrap, ...)
+ if select("#", ...) > 0 then
+ error("GameTooltip:SetText expects text, r, g, b, alpha, wrap", 2)
end
+
+ if r ~= nil and type(r) ~= "number" then
+ error("GameTooltip:SetText expects numeric red channel", 2)
+ end
+ if g ~= nil and type(g) ~= "number" then
+ error("GameTooltip:SetText expects numeric green channel", 2)
+ end
+ if b ~= nil and type(b) ~= "number" then
+ error("GameTooltip:SetText expects numeric blue channel", 2)
+ end
+ if a ~= nil and type(a) ~= "number" then
+ error("GameTooltip:SetText expects numeric alpha channel", 2)
+ end
+ if wrap ~= nil and type(wrap) ~= "boolean" then
+ error("GameTooltip:SetText expects boolean wrap flag", 2)
+ end
+
self._title = text
- self._titleColor = color
- self._titleAlpha = alpha
+ if r ~= nil or g ~= nil or b ~= nil then
+ self._titleColor = { r = r, g = g, b = b, a = a }
+ else
+ self._titleColor = nil
+ end
+ self._titleAlpha = a
self._titleWrap = wrap
self._lines = {}
end,
- AddLine = function(self, text, color, wrap, ...)
- if type(color) == "number" or select("#", ...) > 0 then
- error("GameTooltip:AddLine expects text, color, wrap", 2)
+ AddLine = function(self, text, r, g, b, wrap, ...)
+ if select("#", ...) > 0 then
+ error("GameTooltip:AddLine expects text, r, g, b, wrap", 2)
+ end
+
+ if r ~= nil and type(r) ~= "number" then
+ error("GameTooltip:AddLine expects numeric red channel", 2)
+ end
+ if g ~= nil and type(g) ~= "number" then
+ error("GameTooltip:AddLine expects numeric green channel", 2)
+ end
+ if b ~= nil and type(b) ~= "number" then
+ error("GameTooltip:AddLine expects numeric blue channel", 2)
+ end
+ if wrap ~= nil and type(wrap) ~= "boolean" then
+ error("GameTooltip:AddLine expects boolean wrap flag", 2)
end
self._lines[#self._lines + 1] = text
end,
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index e850d19d..f53fbf34 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -13,8 +13,14 @@ local TestHelpers = assert(
describe("ExtraIconsOptions data helpers", function()
local ExtraIconsOptions, ns
+ local originalCreateColor
setup(function()
+ originalCreateColor = _G.CreateColor
+ _G.CreateColor = function(r, g, b, a)
+ return { r = r, g = g, b = b, a = a or 1 }
+ end
+
ns = {}
_G.Enum = {
PowerType = {
@@ -37,6 +43,10 @@ describe("ExtraIconsOptions data helpers", function()
ExtraIconsOptions = ns.ExtraIconsOptions
end)
+ teardown(function()
+ _G.CreateColor = originalCreateColor
+ end)
+
describe("_isStackKeyPresent", function()
it("finds stackKey in utility viewer", function()
local viewers = { utility = { { stackKey = "trinket1" } }, main = {} }
@@ -751,7 +761,7 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns, capturedTable, refreshCalls, scheduledReasons
+ local profile, defaults, SB, ns, capturedTable, refreshCalls, scheduledReasons, previewCalls
local function buildSections()
return assert(capturedTable.args.viewers.sections())
@@ -765,6 +775,14 @@ describe("ExtraIconsOptions settings page", function()
end
end
+ local function getTrailerValue(trailer, key)
+ local value = trailer[key]
+ if type(value) == "function" then
+ return value()
+ end
+ return value
+ end
+
local function findItem(sectionKey, predicate)
local section = assert(getSection(sectionKey))
for _, item in ipairs(section.items) do
@@ -789,6 +807,7 @@ describe("ExtraIconsOptions settings page", function()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
refreshCalls = {}
scheduledReasons = {}
+ previewCalls = {}
profile.extraIcons = {
enabled = true,
@@ -802,6 +821,9 @@ describe("ExtraIconsOptions settings page", function()
ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
scheduledReasons[#scheduledReasons + 1] = reason
end
+ ns.Runtime.SetLayoutPreview = function(active)
+ previewCalls[#previewCalls + 1] = active
+ end
local originalRegisterFromTable = SB.RegisterFromTable
SB.RegisterFromTable = function(tbl)
@@ -820,6 +842,16 @@ describe("ExtraIconsOptions settings page", function()
assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
end)
+ it("registers page-level onShow and onHide callbacks", function()
+ assert.is_function(capturedTable.onShow)
+ assert.is_function(capturedTable.onHide)
+
+ capturedTable.onShow()
+ capturedTable.onHide()
+
+ assert.are.same({ true, false }, previewCalls)
+ end)
+
it("registers a legend info row and collection widget instead of a canvas", function()
local opts = ns.ExtraIconsOptions
@@ -860,10 +892,11 @@ describe("ExtraIconsOptions settings page", function()
local trailer = assert(getSection("main")).trailer
trailer.onTextChanged("12345")
+ assert.are.same({}, refreshCalls)
trailer = assert(getSection("main")).trailer
- assert.are.equal("Test Spell", trailer.previewText)
- assert.is_true(trailer.submitEnabled)
+ assert.are.equal("Test Spell", getTrailerValue(trailer, "previewText"))
+ assert.is_true(getTrailerValue(trailer, "submitEnabled"))
assert.is_true(trailer.onSubmit())
assert.are.equal("", ns.ExtraIconsOptions._draftStates.main.idText)
@@ -871,7 +904,7 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
- assert.are.same({ category, category }, refreshCalls)
+ assert.are.same({ category }, refreshCalls)
assert.are.same({ "OptionsChanged" }, scheduledReasons)
end)
@@ -897,8 +930,8 @@ describe("ExtraIconsOptions settings page", function()
trailer.onTextChanged("777")
trailer = assert(getSection("utility")).trailer
- assert.are.equal("...", trailer.previewText)
- assert.is_false(trailer.submitEnabled)
+ assert.are.equal("...", getTrailerValue(trailer, "previewText"))
+ assert.is_false(getTrailerValue(trailer, "submitEnabled"))
itemNames[777] = "Loaded Item"
ns.ExtraIconsOptions._itemLoadFrame:GetScript("OnEvent")(
@@ -909,8 +942,8 @@ describe("ExtraIconsOptions settings page", function()
)
trailer = assert(getSection("utility")).trailer
- assert.are.equal("Loaded Item", trailer.previewText)
- assert.is_true(trailer.submitEnabled)
+ assert.are.equal("Loaded Item", getTrailerValue(trailer, "previewText"))
+ assert.is_true(getTrailerValue(trailer, "submitEnabled"))
end)
it("blocks duplicate entries and shows which viewer already owns them", function()
@@ -932,9 +965,9 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(
ns.L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(ns.L["UTILITY_VIEWER_SHORT"]),
- trailer.previewText
+ getTrailerValue(trailer, "previewText")
)
- assert.is_false(trailer.submitEnabled)
+ assert.is_false(getTrailerValue(trailer, "submitEnabled"))
end)
it("reorder, move, and remove actions operate on the stored viewers", function()
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index e228f216..5813c472 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -8,7 +8,6 @@ local L = ns.L
local REMOVE_STALE_SPELL_COLORS_POPUP = "ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS"
local SPELL_COLORS_HEADER_BUTTON_WIDTH = 100
-local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
--- Generates the merged list of spell color rows from spell color entries.
---@param entries { key: ECM_SpellColorKey }[]|nil
@@ -164,10 +163,10 @@ local function maybeShowSpellColorKeyTooltip(owner, data)
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
- GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], TOOLTIP_TITLE_COLOR, 1)
+ GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], 1, 1, 1, 1)
for _, line in ipairs(lines) do
- GameTooltip:AddLine(line, TOOLTIP_TITLE_COLOR, true)
+ GameTooltip:AddLine(line, 1, 1, 1, true)
end
GameTooltip:Show()
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index df7a5117..1f9d18da 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -16,7 +16,6 @@ local VIEWER_COLLECTION_HEIGHT = 448
local DEFAULT_SPECIAL_VIEWER = "utility"
local DRAFT_PENDING_TEXT = "..."
local VIEWER_ORDER = { "utility", "main" }
-local TOOLTIP_TITLE_COLOR = CreateColor(1, 1, 1, 1)
local VIEWER_LABELS = {
utility = "UTILITY_VIEWER_ICONS",
main = "MAIN_VIEWER_ICONS",
@@ -218,26 +217,35 @@ local function buildTooltipLine(...)
return table.concat(parts, " ")
end
+local function setTooltipTitle(text, wrap)
+ GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true)
+end
+
+local function addTooltipLine(text, wrap)
+ if text and text ~= "" then
+ GameTooltip:AddLine(text, 1, 1, 1, wrap == true)
+ end
+end
+
local function addItemStackTooltipLines(entry)
local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
if not stack or stack.kind ~= "item" or not stack.ids or #stack.ids == 0 then
return false
end
- GameTooltip:AddLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], TOOLTIP_TITLE_COLOR, true)
+ addTooltipLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], true)
for _, itemEntry in ipairs(stack.ids) do
local itemId = getItemIdFromEntry(itemEntry)
local icon = itemId and C_Item.GetItemIconByID(itemId) or nil
local itemName = getItemDisplayName(itemId)
local quality = type(itemEntry) == "table" and itemEntry.quality or nil
- GameTooltip:AddLine(
+ addTooltipLine(
buildTooltipLine(
getTextureMarkup(icon, TOOLTIP_ITEM_ICON_SIZE),
itemName or ("Item " .. tostring(itemId)),
getQualityMarkup(quality)
- ),
- TOOLTIP_TITLE_COLOR
+ )
)
end
@@ -671,13 +679,6 @@ end
-- UI: Tooltip helpers
--------------------------------------------------------------------------------
---- Set a simple text tooltip on a button.
-local function addTooltipLine(text)
- if text and text ~= "" then
- GameTooltip:AddLine(text, TOOLTIP_TITLE_COLOR, true)
- end
-end
-
local function showRowTooltip(owner, rowData)
if not rowData then
return
@@ -688,18 +689,18 @@ local function showRowTooltip(owner, rowData)
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
- GameTooltip:SetText(getEntryTooltipTitle(displayEntry), TOOLTIP_TITLE_COLOR, 1)
+ setTooltipTitle(getEntryTooltipTitle(displayEntry))
if rowData.isBuiltin then
if rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
+ addTooltipLine(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], true)
end
elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
+ addTooltipLine(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"], true)
end
if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ addTooltipLine(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"], true)
end
addItemStackTooltipLines(displayEntry)
@@ -972,57 +973,73 @@ function ExtraIconsOptions.RegisterSettings(SB)
local function buildModeInputTrailer(viewerKey)
local draftState = draftStates[viewerKey]
- local controlsDisabled = isDisabled()
- local status, name, icon = getDraftResolution(viewerKey)
- local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
- local previewText
-
- if status == "resolved" and isDuplicate then
- previewText = L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey))
- elseif status == "resolved" then
- previewText = name or ""
- elseif status == "pending" then
- previewText = DRAFT_PENDING_TEXT
+ local function getPreviewState()
+ local status, name, icon = getDraftResolution(viewerKey)
+ local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
+ return status, name, icon, isDuplicate, duplicateViewerKey
end
return {
preset = "modeInput",
- disabled = controlsDisabled,
- modeText = draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"],
+ disabled = function()
+ return isDisabled()
+ end,
+ modeText = function()
+ return draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"]
+ end,
modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
- inputText = draftState.idText,
- placeholder = getDraftPlaceholderText(draftState),
- previewIcon = icon,
- previewText = previewText,
+ inputText = function()
+ return draftState.idText
+ end,
+ placeholder = function()
+ return getDraftPlaceholderText(draftState)
+ end,
+ previewIcon = function()
+ local _, _, icon = getPreviewState()
+ return icon
+ end,
+ previewText = function()
+ local status, name, _, isDuplicate, duplicateViewerKey = getPreviewState()
+ if status == "resolved" and isDuplicate then
+ return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey))
+ end
+ if status == "resolved" then
+ return name or ""
+ end
+ if status == "pending" then
+ return DRAFT_PENDING_TEXT
+ end
+ return nil
+ end,
submitText = L["ADD_ENTRY"],
submitTooltip = L["ADD_ENTRY"],
- submitEnabled = status == "resolved" and not isDuplicate,
+ submitEnabled = function()
+ local status, _, _, isDuplicate = getPreviewState()
+ return status == "resolved" and not isDuplicate
+ end,
onToggleMode = function()
- if controlsDisabled then
+ if isDisabled() then
return
end
draftState.kind = draftState.kind == "spell" and "item" or "spell"
- refreshCategory()
end,
onTextChanged = function(text)
draftState.idText = text or ""
- refreshCategory()
end,
onSubmit = function()
- if controlsDisabled then
+ if isDisabled() then
return false
end
return addDraftEntry(viewerKey)
end,
onTabPressed = function()
- if controlsDisabled then
+ if isDisabled() then
return false
end
draftState.kind = draftState.kind == "spell" and "item" or "spell"
- refreshCategory()
return true
end,
}
From 720d48dae6eba792593d080db1e27e996b6b9fd4 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 14 Apr 2026 16:23:15 +1000
Subject: [PATCH 12/53] Split lib settings builder into smaller files.
Experiment with embedded icons for the extra icons ui.
---
.luacheckrc | 2 +-
AGENTS.md | 77 +
ARCHITECTURE.md | 18 +-
EnhancedCooldownManager.toc | 2 +-
.../CompositeControls/Groups.lua | 166 +
.../CompositeControls/Lists.lua | 40 +
Libs/LibSettingsBuilder/Controls/Base.lua | 253 ++
.../Controls/CollectionFrames.lua | 823 ++++
.../Controls/Collections.lua | 65 +
Libs/LibSettingsBuilder/Controls/Rows.lua | 141 +
Libs/LibSettingsBuilder/Core.lua | 1066 +++++
.../LibSettingsBuilder/LibSettingsBuilder.lua | 3652 -----------------
.../Primitives/BlizzardControls.lua | 423 ++
Libs/LibSettingsBuilder/Primitives/Layout.lua | 59 +
Libs/LibSettingsBuilder/Primitives/Rows.lua | 606 +++
Libs/LibSettingsBuilder/README.md | 53 +-
...tingsBuilder_spec.lua => Builder_spec.lua} | 781 +++-
.../Tests/Collections_spec.lua | 102 +
.../Tests/Controls_spec.lua | 81 +
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 69 +
Libs/LibSettingsBuilder/Utility.lua | 306 ++
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 49 +-
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 13 +-
.../docs/MIGRATION_GUIDE.md | 30 +-
Libs/LibSettingsBuilder/docs/QUICK_START.md | 25 +-
.../docs/TROUBLESHOOTING.md | 2 +-
Libs/LibSettingsBuilder/embed.xml | 13 +
Media/delete_down.tga | Bin 0 -> 1068 bytes
Media/delete_normal.tga | Bin 0 -> 1068 bytes
Media/hide_down.tga | Bin 0 -> 1068 bytes
Media/hide_normal.tga | Bin 0 -> 1068 bytes
Media/move_down_down.tga | Bin 0 -> 1068 bytes
Media/move_down_normal.tga | Bin 0 -> 1068 bytes
Media/move_up_down.tga | Bin 0 -> 1068 bytes
Media/move_up_normal.tga | Bin 0 -> 1068 bytes
Media/preview.png | Bin 0 -> 1701 bytes
Media/show_down.tga | Bin 0 -> 1068 bytes
Media/show_normal.tga | Bin 0 -> 1068 bytes
Media/swap_down.tga | Bin 0 -> 1068 bytes
Media/swap_normal.tga | Bin 0 -> 1068 bytes
Modules/ExtraIcons.lua | 10 +-
Tests/Modules/ExtraIcons_spec.lua | 44 +-
Tests/TestHelpers.lua | 35 +-
Tests/UI/BuffBarsOptions_spec.lua | 48 +-
Tests/UI/BuffBarsSettingsOptions_spec.lua | 41 +-
Tests/UI/ExtraIconsOptions_spec.lua | 435 +-
Tests/UI/LayoutOptions_spec.lua | 20 +-
Tests/UI/OptionsSections_spec.lua | 68 +
Tests/UI/Options_spec.lua | 136 +-
Tests/UI/PowerBarOptions_spec.lua | 18 +-
Tests/UI/PowerBarTickMarksOptions_spec.lua | 30 +-
Tests/UI/ProfileOptions_spec.lua | 42 +-
Tests/UI/ResourceBarOptions_spec.lua | 21 +-
Tests/UI/RuneBarOptions_spec.lua | 17 +-
UI/BuffBarsOptions.lua | 98 +-
UI/ExtraIconsOptions.lua | 1119 ++---
UI/GeneralOptions.lua | 100 +-
UI/LayoutOptions.lua | 53 +-
UI/OptionUtil.lua | 80 +-
UI/Options.lua | 24 +-
UI/PowerBarOptions.lua | 29 +-
UI/PowerBarTickMarksOptions.lua | 40 +-
UI/ProfileOptions.lua | 6 +-
UI/ResourceBarOptions.lua | 34 +-
UI/RuneBarOptions.lua | 59 +-
65 files changed, 6322 insertions(+), 5202 deletions(-)
create mode 100644 Libs/LibSettingsBuilder/CompositeControls/Groups.lua
create mode 100644 Libs/LibSettingsBuilder/CompositeControls/Lists.lua
create mode 100644 Libs/LibSettingsBuilder/Controls/Base.lua
create mode 100644 Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
create mode 100644 Libs/LibSettingsBuilder/Controls/Collections.lua
create mode 100644 Libs/LibSettingsBuilder/Controls/Rows.lua
create mode 100644 Libs/LibSettingsBuilder/Core.lua
delete mode 100644 Libs/LibSettingsBuilder/LibSettingsBuilder.lua
create mode 100644 Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
create mode 100644 Libs/LibSettingsBuilder/Primitives/Layout.lua
create mode 100644 Libs/LibSettingsBuilder/Primitives/Rows.lua
rename Libs/LibSettingsBuilder/Tests/{LibSettingsBuilder_spec.lua => Builder_spec.lua} (77%)
create mode 100644 Libs/LibSettingsBuilder/Tests/Collections_spec.lua
create mode 100644 Libs/LibSettingsBuilder/Tests/Controls_spec.lua
create mode 100644 Libs/LibSettingsBuilder/Tests/Core_spec.lua
create mode 100644 Libs/LibSettingsBuilder/Utility.lua
create mode 100644 Libs/LibSettingsBuilder/embed.xml
create mode 100644 Media/delete_down.tga
create mode 100644 Media/delete_normal.tga
create mode 100644 Media/hide_down.tga
create mode 100644 Media/hide_normal.tga
create mode 100644 Media/move_down_down.tga
create mode 100644 Media/move_down_normal.tga
create mode 100644 Media/move_up_down.tga
create mode 100644 Media/move_up_normal.tga
create mode 100644 Media/preview.png
create mode 100644 Media/show_down.tga
create mode 100644 Media/show_normal.tga
create mode 100644 Media/swap_down.tga
create mode 100644 Media/swap_normal.tga
diff --git a/.luacheckrc b/.luacheckrc
index 8bab74db..840bc006 100644
--- a/.luacheckrc
+++ b/.luacheckrc
@@ -73,7 +73,7 @@ read_globals = {'C_PlayerInfo','DEFAULT_CHAT_FRAME', 'MenuUtil', 'GameTooltip',
"GetInventoryItemCooldown", "GetInventoryItemID", "GetInventoryItemTexture", "GetRuneCooldown",
"GetShapeshiftForm", "GetSpecialization", "GetSpecializationInfo", "GetSpecializationRole",
"hooksecurefunc",
- "InCombatLockdown", "IsControlKeyDown", "IsInInstance", "IsMounted", "IsPlayerSpell", "IsResting",
+ "InCombatLockdown", "IsControlKeyDown", "IsInInstance", "IsMounted", "IsResting",
"issecrettable", "issecretvalue",
"LibStub",
"NO",
diff --git a/AGENTS.md b/AGENTS.md
index 29558808..858628de 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -65,6 +65,17 @@ luacheck . -q
- Remove dead code, stale fields, impossible branches, and unused locale strings.
- Clear critical state flags with `pcall` or equivalent so one error cannot wedge later work.
+## Code Density
+
+- Inline single-use local functions into their sole call site. Do not extract a helper just to give a three-line block a name.
+- When multiple table literals share identical structure (e.g. `{ normal = base .. X .. "_normal", pushed = base .. X .. "_down" }`), generate them with a constructor instead of writing each one out.
+- When the same two- or three-call sequence repeats across many callbacks (e.g. `scheduleUpdate(); refreshCategory()`), extract one thin wrapper and call it everywhere.
+- Use `O(1)` set lookups (`SET[key]`) instead of linear scans (`for i, v in ipairs(list)`) when the list is fixed at load time.
+- Prefer compact single-line bodies for trivial functions: `local function f() return x end`.
+- When building repetitive declarative structures (action buttons, menu items), extract a factory that takes only the varying parts and returns the full structure.
+- Do not assign fields to `nil` just to "clear" them. Only assign fields that will be read later.
+- Closures that differ only in one value (e.g. direction = -1 vs +1) should call through a shared parameterised path, not duplicate the surrounding code.
+
## Tests, Libraries, and Migrations
- Be skeptical about changing tests to satisfy failures; the failure may be real.
@@ -97,3 +108,69 @@ luacheck . -q
- Do not do arithmetic, comparisons, boolean tests, length, indexing, assignment, iteration, or use them as table keys.
- Storing secret values in locals, upvalues, or table values is allowed; concatenation and string formatting with string or number secrets is allowed.
- Secret tables may always yield secret values or be fully inaccessible; `canaccesstable(table)` only tells you whether access would be allowed.
+
+---
+
+# Deprecated Blizzard APIs (12.0.5)
+
+Do not use any of the functions, constants, or mixins listed below. They are deprecated shims provided by Blizzard for backward compatibility and may be removed in a future patch. Use the modern replacement shown in the Blizzard source (typically a `C_*` namespace method or mixin method) instead.
+
+Source: `Blizzard_Deprecated*` folders in https://github.com/Gethe/wow-ui-source/tree/12.0.5/Interface/AddOns
+
+## Blizzard_Deprecated
+
+- `GetBattlefieldScore`
+- `GetBattlefieldStatData`
+- `UnitIsSpellTarget`
+- `C_SpellBook.GetSpellBookItemLossOfControlCooldown`
+
+## Blizzard_DeprecatedChatInfo
+
+Constants: `CHAT_BUTTON_FLASH_TIME`, `CHAT_TELL_ALERT_TIME`, `MAX_COMMUNITY_NAME_LENGTH`, `MAX_COMMUNITY_NAME_LENGTH_NO_CHANNEL`, `MAX_REMEMBERED_TELLS`, `MESSAGE_SCROLLBUTTON_INITIAL_DELAY`, `MESSAGE_SCROLLBUTTON_SCROLL_DELAY`, `MAX_WOW_CHAT_CHANNELS`, `MAX_CHARACTER_NAME_BYTES`, `NUM_CHAT_WINDOWS`, `MAX_COUNTDOWN_SECONDS`
+
+ChatFrameUtil aliases: `Chat_AddSystemMessage`, `Chat_GetChannelColor`, `Chat_GetChannelShortcutName`, `Chat_GetChatCategory`, `Chat_GetChatFrame`, `Chat_GetColoredChatName`, `Chat_GetCommunitiesChannel`, `Chat_GetCommunitiesChannelColor`, `Chat_GetCommunitiesChannelName`, `Chat_ShouldColorChatByClass`, `ChatEdit_ActivateChat`, `ChatEdit_ChooseBoxForSend`, `ChatEdit_DeactivateChat`, `ChatEdit_FocusActiveWindow`, `ChatEdit_GetActiveChatType`, `ChatEdit_GetActiveWindow`, `ChatEdit_GetLastActiveWindow`, `ChatEdit_GetLastTellTarget`, `ChatEdit_GetLastToldTarget`, `ChatEdit_GetNextTellTarget`, `ChatEdit_HasStickyFocus`, `ChatEdit_InsertLink`, `ChatEdit_LinkItem`, `ChatEdit_SetLastActiveWindow`, `ChatEdit_SetLastTellTarget`, `ChatEdit_SetLastToldTarget`, `ChatEdit_TryInsertChatLink`, `ChatEdit_TryInsertQuestLinkForQuestID`, `ChatFrame_AddCommunitiesChannel`, `ChatFrame_AddMessageEventFilter`, `ChatFrame_CanAddChannel`, `ChatFrame_CanChatGroupPerformExpressionExpansion`, `ChatFrame_ChatPageDown`, `ChatFrame_ChatPageUp`, `ChatFrame_ClearChatFocusOverride`, `ChatFrame_DisplayChatHelp`, `ChatFrame_DisplayGameTime`, `ChatFrame_DisplayGMOTD`, `ChatFrame_DisplayHelpText`, `ChatFrame_DisplayHelpTextSimple`, `ChatFrame_DisplayMacroHelpText`, `ChatFrame_DisplaySystemMessage`, `ChatFrame_DisplaySystemMessageInCurrent`, `ChatFrame_DisplaySystemMessageInPrimary`, `ChatFrame_DisplayTimePlayed`, `ChatFrame_DisplayUsageError`, `ChatFrame_GetChatFocusOverride`, `ChatFrame_GetCommunitiesChannelLocalID`, `ChatFrame_GetCommunityAndStreamFromChannel`, `ChatFrame_GetCommunityAndStreamName`, `ChatFrame_GetFullChannelInfo`, `ChatFrame_GetMobileEmbeddedTexture`, `ChatFrame_OpenChat`, `ChatFrame_RemoveCommunitiesChannel`, `ChatFrame_RemoveMessageEventFilter`, `ChatFrame_ReplyTell`, `ChatFrame_ReplyTell2`, `ChatFrame_ResolveChannelName`, `ChatFrame_ResolvePrefixedChannelName`, `ChatFrame_ScrollDown`, `ChatFrame_ScrollToBottom`, `ChatFrame_ScrollUp`, `ChatFrame_SendTell`, `ChatFrame_SendTellWithMessage`, `ChatFrame_SetChatFocusOverride`, `ChatFrame_TimeBreakDown`, `ChatFrame_TruncateToMaxLength`, `ChatFrame_UpdateChatFrames`, `GetChatTimestampFormat`, `SubstituteChatMessageBeforeSend`
+
+ChatFrameMixin aliases: `ChatFrame_AddMessage`, `ChatFrame_AddMessageGroup`, `ChatFrame_AddPrivateMessageTarget`, `ChatFrame_AddSingleMessageType`, `ChatFrame_ContainsChannel`, `ChatFrame_ContainsMessageGroup`, `ChatFrame_ExcludePrivateMessageTarget`, `ChatFrame_GetDefaultChatTarget`, `ChatFrame_ReceiveAllPrivateMessages`, `ChatFrame_RegisterForChannels`, `ChatFrame_RegisterForMessages`, `ChatFrame_RemoveAllChannels`, `ChatFrame_RemoveAllMessageGroups`, `ChatFrame_RemoveChannel`, `ChatFrame_RemoveExcludePrivateMessageTarget`, `ChatFrame_RemoveMessageGroup`, `ChatFrame_RemovePrivateMessageTarget`, `ChatFrame_UnregisterAllMessageGroups`, `ChatFrame_UpdateColorByID`, `ChatFrame_UpdateDefaultChatTarget`
+
+ChatFrameEditBoxMixin aliases: `ChatEdit_AddHistory`, `ChatEdit_ClearChat`, `ChatEdit_DoesCurrentChannelTargetMatch`, `ChatEdit_ExtractChannel`, `ChatEdit_ExtractTellTarget`, `ChatEdit_GetChannelTarget`, `ChatEdit_HandleChatType`, `ChatEdit_ParseText`, `ChatEdit_ResetChatType`, `ChatEdit_ResetChatTypeToSticky`, `ChatEdit_SendText`, `ChatEdit_SetDeactivated`, `ChatEdit_UpdateHeader`
+
+API functions: `SendChatMessage`, `DoEmote`, `CancelEmote`
+
+## Blizzard_DeprecatedInstanceEncounter
+
+- `IsEncounterInProgress`, `IsEncounterSuppressingRelease`, `IsEncounterLimitingResurrections`
+
+## Blizzard_DeprecatedItemScript
+
+- `GetItemQualityColor`, `GetItemInfoInstant`, `GetItemSetInfo`, `GetItemChildInfo`, `DoesItemContainSpec`, `GetItemGem`, `GetItemCreationContext`, `GetItemIcon`, `GetItemFamily`, `GetItemSpell`
+- `IsArtifactPowerItem`, `IsCurrentItem`, `IsUsableItem`, `IsHelpfulItem`, `IsHarmfulItem`, `IsConsumableItem`, `IsEquippableItem`, `IsEquippedItem`, `IsEquippedItemType`
+- `ItemHasRange`, `IsItemInRange`, `GetItemClassInfo`, `GetItemInventorySlotInfo`
+- `BindEnchant`, `ActionBindsItem`, `ReplaceEnchant`, `ReplaceTradeEnchant`, `ConfirmBindOnUse`, `ConfirmOnUse`, `ConfirmNoRefundOnUse`
+- `DropItemOnUnit`, `EndBoundTradeable`, `EndRefund`
+- `GetItemInfo`, `GetDetailedItemLevelInfo`, `GetItemSpecInfo`, `GetItemUniqueness`, `GetItemCount`, `PickupItem`, `GetItemSubClassInfo`
+- `UseItemByName`, `EquipItemByName`, `ReplaceTradeskillEnchant`, `GetItemCooldown`
+- `IsCorruptedItem`, `IsCosmeticItem`, `IsDressableItem`
+
+## Blizzard_DeprecatedPvpScript
+
+- `IsSubZonePVPPOI`, `GetZonePVPInfo`, `TogglePVP`, `SetPVP`
+
+## Blizzard_DeprecatedSpecialization
+
+Standard: `GetNumSpecializationsForClassID`, `GetSpecializationInfo`, `GetSpecialization`, `GetActiveSpecGroup`, `GetSpecializationMasterySpells`, `GetTalentInfo`
+
+Classic variants: `SetActiveTalentGroup`, `GetTalentTabInfo`, `GetPrimaryTalentTree`, `GetActiveTalentGroup`, `GetTalentTreeMasterySpells`
+
+Constants: `MAX_TALENT_TIERS`, `NUM_TALENT_COLUMNS`
+
+## Blizzard_DeprecatedSpellBook
+
+Constant: `HUNTER_DISMISS_PET`
+
+Functions: `IsPlayerSpell`, `IsSpellKnown`, `IsSpellKnownOrOverridesKnown`, `FindFlyoutSlotBySpellID`, `FindSpellOverrideByID`, `FindBaseSpellByID`
+
+## Blizzard_DeprecatedSpellScript
+
+- `TargetSpellReplacesBonusTree`, `GetMaxSpellStartRecoveryOffset`, `GetSpellQueueWindow`, `GetSchoolString`
+- `SpellIsPriorityAura`, `SpellIsSelfBuff`, `SpellGetVisibilityInfo`
+- `C_Spell.GetSpellLossOfControlCooldown`
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 61e8f871..33d41f52 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -192,12 +192,14 @@ LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced
### Options UI
Setting changes flow through LibSettingsBuilder's `onChange` → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`.
+The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, which guarantees `Core.lua`, the primitive helper modules, standard control modules, composite control modules, and `Utility.lua` initialize in order before options pages register.
-Options pages now use one LibSettingsBuilder DSL for both simple and complex screens:
+Options pages now use `SB.RegisterPage(...)` plus canonical row types:
-- standard persisted controls (`toggle`, `range`, `select`, `color`, `input`) bind through path mode or handler mode,
-- layout rows (`header`, `subheader`, `info`, `button`) handle structure and copy,
-- dynamic editors use `collection` rows with library-owned list/section rendering plus `SB.RefreshCategory(...)` for async or transient state such as profile pickers and item-preview updates.
+- standard persisted controls (`checkbox`, `slider`, `dropdown`, `color`, `input`) bind through path mode or handler mode,
+- layout rows (`header`, `subheader`, `info`, `button`, `pageActions`, `canvas`) handle structure and copy,
+- dynamic editors use `list` and `sectionList` rows with library-owned rendering plus `SB.RefreshCategory(...)` for async or transient state such as profile pickers and item-preview updates,
+- `canvas` rows stay on the existing lifecycle path so page switches do not lose or misplace embedded content.
### Watchdog Ticker
@@ -367,7 +369,7 @@ Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Use
|------|--------------|------------|-----------------|
| `equipSlot` | `slotId` | `GetInventoryItemID` + `C_Item.GetItemSpell` on-use check | `GetInventoryItemCooldown` |
| `item` | `ids[]` (priority stack) | First with `C_Item.GetItemCount > 0` | `C_Item.GetItemCooldown` |
-| `spell` | `ids[]` (priority stack) | First with `IsPlayerSpell` → `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
+| `spell` | `ids[]` (priority stack) | First known via `C_SpellBook.IsSpellKnown`, then `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Built-in entries may also persist `disabled = true`, which keeps them in the settings list but skips them during runtime resolution. Custom and racial entries store fields directly in saved config.
@@ -388,7 +390,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `RegisterFromTable` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the canvas as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section. The current-player racial is also synthesized as a disabled placeholder when absent; adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables. Special-row behavior is explained through a short legend plus row-specific tooltips.
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `SB.RegisterPage(...)` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the canvas as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is also synthesized as a disabled placeholder when absent; if race lookup misses, the fallback known-racial scan uses the same spellbook-aware check as runtime resolution so racials like Shadowmeld still surface in the settings list. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the category so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Canvas rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
### FrameUtil (`ns.FrameUtil`)
@@ -484,8 +486,8 @@ Shared helpers for the Settings UI, used by all option pages.
| `GetCurrentClassSpec()` | Return `(classID, specIndex, className, specName, classEnum)` |
| `GetIsDisabledDelegate(configPath)` | Return closure checking if module is disabled |
| `CreateModuleEnabledHandler(moduleName, requiresReload?)` | Create enable/disable toggle handler |
-| `CreateBarArgs(isDisabled, options?)` | Generate standard bar layout/appearance args |
-| `CreateDetachedStackArgs()` | Generate detached positioning args |
+| `CreateBarRows(isDisabled, options?)` | Generate standard bar layout/appearance rows |
+| `CreateDetachedStackRows()` | Generate detached positioning rows |
| `CreateDetachedAnchorEditModeSettings(getGlobalConfig, onChanged)` | Create Edit Mode settings for detached anchor |
| `OpenColorPicker(currentColor, hasOpacity, onChange)` | Open Blizzard color picker |
| `MakeConfirmDialog(text)` | Create confirm dialog for `StaticPopup` |
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index af227a53..9950cc21 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -20,7 +20,7 @@ Libs\LibSerialize\lib.xml
Libs\LibDeflate\lib.xml
Libs\LibEditMode\embed.xml
Libs\LibSharedMedia-3.0\lib.xml
-Libs\LibSettingsBuilder\LibSettingsBuilder.lua
+Libs\LibSettingsBuilder\embed.xml
Libs\LibLSMSettingsWidgets\LibLSMSettingsWidgets.xml
Constants.lua
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
new file mode 100644
index 00000000..eea8e3fe
--- /dev/null
+++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
@@ -0,0 +1,166 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+function lib._installCompositeGroups(SB, env)
+ local mergeCompositeDefaults = env.mergeCompositeDefaults
+ local propagateModifiers = env.propagateModifiers
+
+ function SB.HeightOverrideSlider(sectionPath, spec)
+ spec = spec or {}
+ local childSpec = {
+ path = sectionPath .. ".height",
+ name = spec.name or "Height Override",
+ tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.",
+ min = spec.min or 0,
+ max = spec.max or 40,
+ step = spec.step or 1,
+ getTransform = function(value)
+ return value or 0
+ end,
+ setTransform = function(value)
+ return value > 0 and value or nil
+ end,
+ }
+ propagateModifiers(childSpec, spec)
+ return SB.Slider(childSpec)
+ end
+
+ --- Font override group.
+ --- Optional spec fields:
+ --- fontValues function() -> table (choices for the dropdown)
+ --- fontFallback function() -> string (fallback font name)
+ --- fontSizeFallback function() -> number (fallback font size)
+ --- fontTemplate string (custom template for the font picker)
+ function SB.FontOverrideGroup(sectionPath, spec)
+ spec = mergeCompositeDefaults("FontOverrideGroup", spec)
+ local overridePath = sectionPath .. ".overrideFont"
+
+ local enabledSpec = {
+ path = overridePath,
+ name = spec.enabledName or "Override font",
+ tooltip = spec.enabledTooltip or "Override the global font settings for this module.",
+ getTransform = function(value)
+ return value == true
+ end,
+ }
+ propagateModifiers(enabledSpec, spec)
+ local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
+
+ local outerDisabled = spec.disabled
+ local function isOverrideDisabled()
+ if outerDisabled and outerDisabled() then
+ return true
+ end
+ return not enabledSetting:GetValue()
+ end
+
+ local fontSpec = {
+ path = sectionPath .. ".font",
+ name = spec.fontName or "Font",
+ tooltip = spec.fontTooltip,
+ values = spec.fontValues,
+ disabled = isOverrideDisabled,
+ getTransform = function(value)
+ if value then
+ return value
+ end
+ if spec.fontFallback then
+ return spec.fontFallback()
+ end
+ return nil
+ end,
+ }
+ propagateModifiers(fontSpec, spec)
+
+ local fontInit
+ if spec.fontTemplate then
+ fontSpec.template = spec.fontTemplate
+ fontInit = SB.Custom(fontSpec)
+ else
+ fontInit = SB.Dropdown(fontSpec)
+ end
+
+ local sizeSpec = {
+ path = sectionPath .. ".fontSize",
+ name = spec.sizeName or "Font Size",
+ tooltip = spec.sizeTooltip,
+ min = spec.sizeMin or 6,
+ max = spec.sizeMax or 32,
+ step = spec.sizeStep or 1,
+ disabled = isOverrideDisabled,
+ getTransform = function(value)
+ if value then
+ return value
+ end
+ if spec.fontSizeFallback then
+ return spec.fontSizeFallback()
+ end
+ return 11
+ end,
+ }
+ propagateModifiers(sizeSpec, spec)
+ local sizeInit = SB.Slider(sizeSpec)
+
+ return {
+ enabledInit = enabledInit,
+ enabledSetting = enabledSetting,
+ fontInit = fontInit,
+ sizeInit = sizeInit,
+ }
+ end
+
+ function SB.BorderGroup(borderPath, spec)
+ spec = spec or {}
+
+ local enabledSpec = {
+ path = borderPath .. ".enabled",
+ name = spec.enabledName or "Show border",
+ tooltip = spec.enabledTooltip,
+ }
+ propagateModifiers(enabledSpec, spec)
+ local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
+
+ local thicknessSpec = {
+ path = borderPath .. ".thickness",
+ name = spec.thicknessName or "Border width",
+ tooltip = spec.thicknessTooltip,
+ min = spec.thicknessMin or 1,
+ max = spec.thicknessMax or 10,
+ step = spec.thicknessStep or 1,
+ parent = enabledInit,
+ parentCheck = function()
+ return enabledSetting:GetValue()
+ end,
+ }
+ propagateModifiers(thicknessSpec, spec)
+ local thicknessInit = SB.Slider(thicknessSpec)
+
+ local colorSpec = {
+ path = borderPath .. ".color",
+ name = spec.colorName or "Border color",
+ tooltip = spec.colorTooltip,
+ parent = enabledInit,
+ parentCheck = function()
+ return enabledSetting:GetValue()
+ end,
+ }
+ propagateModifiers(colorSpec, spec)
+ local colorInit = SB.Color(colorSpec)
+
+ return {
+ enabledInit = enabledInit,
+ enabledSetting = enabledSetting,
+ thicknessInit = thicknessInit,
+ colorInit = colorInit,
+ }
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
new file mode 100644
index 00000000..b99258fe
--- /dev/null
+++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
@@ -0,0 +1,40 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+function lib._installCompositeListControls(SB, env)
+ local propagateModifiers = env.propagateModifiers
+
+ local function buildControlList(basePath, defs, spec, factory)
+ local results = {}
+ spec = spec or {}
+ for _, def in ipairs(defs) do
+ local childSpec = {
+ path = basePath .. "." .. tostring(def.key),
+ name = def.name,
+ tooltip = def.tooltip,
+ }
+ propagateModifiers(childSpec, spec)
+ local init, setting = factory(childSpec)
+ results[#results + 1] = { key = def.key, initializer = init, setting = setting }
+ end
+
+ return results
+ end
+
+ function SB.ColorPickerList(basePath, defs, spec)
+ return buildControlList(basePath, defs, spec, SB.Color)
+ end
+
+ function SB.CheckboxList(basePath, defs, spec)
+ return buildControlList(basePath, defs, spec, SB.Checkbox)
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
new file mode 100644
index 00000000..b7a0db80
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -0,0 +1,253 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local createCustomListRowInitializer = internal.createCustomListRowInitializer
+local getOrderedValueEntries = internal.getOrderedValueEntries
+local getSettingVariable = internal.getSettingVariable
+local applyInputRowEnabledState = internal.applyInputRowEnabledState
+local applyInputRowFrame = internal.applyInputRowFrame
+local cancelInputPreviewTimer = internal.cancelInputPreviewTimer
+
+function lib._installStandardControls(SB, env)
+ local applyModifiers = env.applyModifiers
+ local colorTableToHex = env.colorTableToHex
+ local defaultSliderFormatter = env.defaultSliderFormatter
+ local makeProxySetting = env.makeProxySetting
+ local makeVarName = env.makeVarName
+ local makeVarNameFromIdentifier = env.makeVarNameFromIdentifier
+ local postSet = env.postSet
+ local registerCategoryRefreshable = env.registerCategoryRefreshable
+ local resolveBinding = env.resolveBinding
+ local resolveCategory = env.resolveCategory
+ local validateSpecFields = env.validateSpecFields
+
+ function SB.Checkbox(spec)
+ validateSpecFields("checkbox", spec)
+ local setting, cat = makeProxySetting(spec, Settings.VarType.Boolean, false)
+ local initializer = Settings.CreateCheckbox(cat, setting, spec.tooltip)
+ applyModifiers(initializer, spec)
+ return initializer, setting
+ end
+
+ function SB.Slider(spec)
+ validateSpecFields("slider", spec)
+ local setting, cat = makeProxySetting(spec, Settings.VarType.Number, 0)
+
+ local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1)
+ options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or defaultSliderFormatter)
+
+ local initializer = Settings.CreateSlider(cat, setting, options, spec.tooltip)
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
+ function SB.Dropdown(spec)
+ validateSpecFields("dropdown", spec)
+ local binding = resolveBinding(spec)
+ local cat = resolveCategory(spec)
+
+ local default = binding.default
+ if spec.getTransform then
+ default = spec.getTransform(default)
+ end
+
+ local varType = spec.varType
+ or (type(default) == "number" and Settings.VarType.Number)
+ or Settings.VarType.String
+
+ local setting = makeProxySetting(spec, varType, "", binding)
+ local function optionsGenerator()
+ local container = Settings.CreateControlTextContainer()
+ local values = type(spec.values) == "function" and spec.values() or spec.values
+ if values then
+ for _, entry in ipairs(getOrderedValueEntries(values)) do
+ container:Add(entry.value, entry.label)
+ end
+ end
+ return container:GetData()
+ end
+ setting._optionsGen = optionsGenerator
+
+ local initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
+ if spec.scrollHeight then
+ initializer._lsbData = {
+ _lsbKind = "scrollDropdown",
+ setting = setting,
+ values = spec.values,
+ scrollHeight = spec.scrollHeight,
+ name = spec.name,
+ tooltip = spec.tooltip,
+ }
+ if initializer.SetSetting then
+ initializer:SetSetting(setting)
+ end
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.RefreshDropdownText then
+ frame:RefreshDropdownText()
+ end
+ end
+ registerCategoryRefreshable(cat, initializer)
+ end
+
+ if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
+ initializer:SetSetting(setting)
+ end
+ if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.InitDropdown then
+ frame:InitDropdown(initializer)
+ elseif frame and frame.SetValue and setting.GetValue then
+ frame:SetValue(setting:GetValue())
+ end
+ end
+ registerCategoryRefreshable(cat, initializer)
+ end
+
+ if not initializer.GetSetting then
+ initializer.GetSetting = function()
+ return setting
+ end
+ end
+
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
+ function SB.Color(spec)
+ validateSpecFields("color", spec)
+ local variable = makeVarName(spec)
+ local cat = resolveCategory(spec)
+ local binding = resolveBinding(spec)
+
+ local function getter()
+ local tbl = binding.get()
+ return colorTableToHex(tbl)
+ end
+
+ local settingRef
+
+ local function setter(hexValue)
+ local color = CreateColorFromHexString(hexValue)
+ local tbl = { r = color.r, g = color.g, b = color.b, a = color.a }
+ binding.set(tbl)
+ postSet(spec, tbl, settingRef)
+ end
+
+ local defaultTbl = binding.default or {}
+ local defaultHex = colorTableToHex(defaultTbl)
+
+ local setting =
+ Settings.RegisterProxySetting(cat, variable, Settings.VarType.String, spec.name, defaultHex, getter, setter)
+ settingRef = setting
+
+ local initializer = Settings.CreateColorSwatch(cat, setting, spec.tooltip)
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
+ function SB.Input(spec)
+ validateSpecFields("input", spec)
+
+ local setting, cat = makeProxySetting(spec, Settings.VarType.String, "")
+ local data = {
+ debounce = spec.debounce,
+ maxLetters = spec.maxLetters,
+ name = spec.name,
+ numeric = spec.numeric,
+ onTextChanged = spec.onTextChanged,
+ resolveText = spec.resolveText,
+ setting = setting,
+ settingVariable = getSettingVariable(setting),
+ tooltip = spec.tooltip,
+ width = spec.width,
+ }
+
+ local watchVariables = {}
+ if spec.watch then
+ for _, identifier in ipairs(spec.watch) do
+ watchVariables[#watchVariables + 1] = makeVarNameFromIdentifier(identifier)
+ end
+ end
+ if spec.watchVariables then
+ for _, variable in ipairs(spec.watchVariables) do
+ watchVariables[#watchVariables + 1] = variable
+ end
+ end
+ if #watchVariables > 0 then
+ data.watchVariables = watchVariables
+ end
+
+ local extent = spec.resolveText and 46 or 26
+ local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
+ local originalInitFrame = initializer.InitFrame
+ local originalResetter = initializer.Resetter
+
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ if controlInitializer._lsbActiveFrame then
+ applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled)
+ end
+ end
+
+ initializer.InitFrame = function(controlInitializer, frame)
+ controlInitializer._lsbActiveFrame = frame
+ originalInitFrame(controlInitializer, frame)
+ applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false)
+ end
+
+ initializer.Resetter = function(controlInitializer, frame)
+ cancelInputPreviewTimer(frame)
+ if frame and frame._lsbInputEditBox then
+ if frame._lsbInputEditBox.ClearFocus then
+ frame._lsbInputEditBox:ClearFocus()
+ end
+ frame._lsbInputEditBox._lsbOwnerFrame = nil
+ end
+ frame._lsbInputData = nil
+ frame._lsbInputSetting = nil
+ if controlInitializer._lsbActiveFrame == frame then
+ controlInitializer._lsbActiveFrame = nil
+ end
+ originalResetter(controlInitializer, frame)
+ end
+
+ Settings.RegisterInitializer(cat, initializer)
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
+ --- Creates a proxy setting backed by a custom frame template.
+ --- The template's Init receives initializer data containing {setting, name, tooltip}.
+ function SB.Custom(spec)
+ validateSpecFields("custom", spec)
+ assert(spec.template, "Custom: spec.template is required")
+ local setting, cat = makeProxySetting(spec, spec.varType or Settings.VarType.String, "")
+
+ local initializer =
+ Settings.CreateElementInitializer(spec.template, { name = spec.name, tooltip = spec.tooltip })
+
+ if initializer.SetSetting then
+ initializer:SetSetting(setting)
+ end
+
+ Settings.RegisterInitializer(cat, initializer)
+ applyModifiers(initializer, spec)
+
+ return initializer, setting
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
new file mode 100644
index 00000000..e59c4385
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -0,0 +1,823 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local ADD = _G.ADD
+local REMOVE = _G.REMOVE
+
+local internal = lib._internal
+local applyActionButtonTextures = internal.applyActionButtonTextures
+local configureInlineSlider = internal.configureInlineSlider
+local evaluateStaticOrFunction = internal.evaluateStaticOrFunction
+local setGameTooltipText = internal.setGameTooltipText
+local setSimpleTooltip = internal.setSimpleTooltip
+local setTextureValue = internal.setTextureValue
+local showFrame = internal.showFrame
+
+local function applyCollectionRowStyle(row, item)
+ local alpha = item and item.alpha or 1
+
+ if row._label and row._label.SetFontObject and item and item.labelFontObject then
+ row._label:SetFontObject(item.labelFontObject)
+ end
+ if row._label and row._label.SetTextColor and item and item.labelColor then
+ row._label:SetTextColor(
+ item.labelColor[1] or 1,
+ item.labelColor[2] or 1,
+ item.labelColor[3] or 1,
+ item.labelColor[4] or 1
+ )
+ end
+ if row._label and row._label.SetAlpha then
+ row._label:SetAlpha(alpha)
+ end
+ if row._icon and row._icon.SetAlpha then
+ row._icon:SetAlpha(alpha)
+ end
+ if row._icon and row._icon.SetDesaturated then
+ row._icon:SetDesaturated(item and item.iconDesaturated == true or false)
+ end
+ if row._icon and row._icon.SetVertexColor then
+ local color = item and item.iconVertexColor
+ if color then
+ row._icon:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
+ else
+ row._icon:SetVertexColor(1, 1, 1, 1)
+ end
+ end
+end
+
+local function bindCollectionRowTooltip(row, item)
+ if not row or not row.SetScript then
+ return
+ end
+
+ if row.EnableMouse then
+ row:EnableMouse(item ~= nil)
+ end
+
+ row:SetScript("OnEnter", nil)
+ row:SetScript("OnLeave", nil)
+
+ if not item then
+ return
+ end
+
+ row:SetScript("OnEnter", function(self)
+ if self._highlight and self._highlight.Show then
+ self._highlight:Show()
+ end
+ if item.onEnter then
+ item.onEnter(self, item)
+ elseif item.tooltip then
+ if GameTooltip then
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ setGameTooltipText(item.tooltip, true)
+ GameTooltip:Show()
+ end
+ end
+ end)
+ row:SetScript("OnLeave", function(self)
+ if self._highlight and self._highlight.Hide then
+ self._highlight:Hide()
+ end
+ if item.onLeave then
+ item.onLeave(self, item)
+ elseif GameTooltip_Hide then
+ GameTooltip_Hide()
+ end
+ end)
+end
+
+local function ensureHighlight(row)
+ if row._highlight then
+ return row._highlight
+ end
+
+ local highlight = row:CreateTexture(nil, "BACKGROUND")
+ highlight:SetAllPoints()
+ highlight:SetColorTexture(1, 1, 1, 0.08)
+ highlight:Hide()
+ row._highlight = highlight
+ return highlight
+end
+
+local DEFAULT_SWATCH_CENTER_X = internal.defaultSwatchCenterX or -73
+
+local function ensureSwatchCollectionRow(row)
+ if row._lsbSwatchRow then
+ return
+ end
+
+ row._lsbSwatchRow = true
+ row:SetHeight(26)
+ ensureHighlight(row)
+
+ row._icon = row:CreateTexture(nil, "ARTWORK")
+ row._icon:SetPoint("LEFT", 0, 0)
+ row._icon:SetSize(16, 16)
+ row._icon:Hide()
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
+ row._label:SetJustifyH("LEFT")
+ row._label:SetWordWrap(false)
+
+ row._swatch = lib.CreateColorSwatch(row)
+ row._swatch:SetPoint("LEFT", row, "CENTER", DEFAULT_SWATCH_CENTER_X, 0)
+end
+
+local function refreshSwatchCollectionRow(row, item)
+ ensureSwatchCollectionRow(row)
+
+ if item.icon then
+ setTextureValue(row._icon, item.icon)
+ row._icon:Show()
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ else
+ setTextureValue(row._icon, nil)
+ row._icon:Hide()
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
+ end
+ row._label:SetPoint("RIGHT", row._swatch, "LEFT", -8, 0)
+
+ row._label:SetText(item.label or "")
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local color = item.color or {}
+ local colorValue = color.value or color
+ row._swatch:SetColorRGB(colorValue.r or 1, colorValue.g or 1, colorValue.b or 1)
+ setSimpleTooltip(row._swatch, item.swatchTooltip or color.tooltip)
+ row._swatch:SetScript("OnClick", function()
+ local onClick = color.onClick or item.onColorClick
+ if onClick then
+ onClick(item, row)
+ end
+ end)
+ if row._swatch.SetEnabled then
+ row._swatch:SetEnabled(
+ evaluateStaticOrFunction(item.enabled, item, row) ~= false
+ and evaluateStaticOrFunction(color.enabled, item, row) ~= false
+ )
+ end
+end
+
+local function ensureEditorCollectionRow(row)
+ if row._lsbEditorRow then
+ return
+ end
+
+ row._lsbEditorRow = true
+ row:SetHeight(34)
+ ensureHighlight(row)
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row, "LEFT", 10, 0)
+ row._label:SetWidth(70)
+ row._label:SetJustifyH("LEFT")
+
+ row._fieldWidgets = {}
+ row._swatch = lib.CreateColorSwatch(row)
+ row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._removeButton:SetSize(70, 22)
+end
+
+local function ensureEditorFieldWidgets(row, index)
+ local widgets = row._fieldWidgets[index]
+ if widgets then
+ return widgets
+ end
+
+ local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
+ local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ valueText:SetJustifyH("LEFT")
+
+ widgets = {
+ slider = slider,
+ valueText = valueText,
+ }
+ row._fieldWidgets[index] = widgets
+ return widgets
+end
+
+local function refreshEditorCollectionRow(row, item)
+ ensureEditorCollectionRow(row)
+
+ row._label:SetText(item.label or "")
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local previousValueText = nil
+ local fields = item.fields or {}
+
+ for i = 1, #fields do
+ local field = fields[i]
+ local widgets = ensureEditorFieldWidgets(row, i)
+ local slider = widgets.slider
+ local valueText = widgets.valueText
+ local minValue, maxValue, step = field.min or 0, field.max or 1, field.step or 1
+
+ if field.getRange then
+ local nextMin, nextMax, nextStep = field.getRange(item, field.value)
+ if nextMin ~= nil then
+ minValue = nextMin
+ end
+ if nextMax ~= nil then
+ maxValue = nextMax
+ end
+ if nextStep ~= nil then
+ step = nextStep
+ end
+ end
+
+ field.min = minValue
+ field.max = maxValue
+ field.step = step
+
+ slider:ClearAllPoints()
+ if previousValueText then
+ slider:SetPoint("LEFT", previousValueText, "RIGHT", field.gap or 12, 0)
+ else
+ slider:SetPoint("LEFT", row._label, "RIGHT", 8, 0)
+ end
+ slider:SetWidth(field.sliderWidth or 120)
+
+ valueText:ClearAllPoints()
+ valueText:SetPoint("LEFT", slider, "RIGHT", 6, 0)
+ valueText:SetWidth(field.valueWidth or 40)
+
+ configureInlineSlider(slider, valueText, field, function(rounded)
+ if row._lsbRefreshing then
+ return
+ end
+ if field.onValueChanged then
+ field.onValueChanged(rounded, item, row)
+ end
+ end)
+
+ previousValueText = valueText
+ end
+
+ local color = item.color or {}
+ row._swatch:ClearAllPoints()
+ if previousValueText then
+ row._swatch:SetPoint("LEFT", previousValueText, "RIGHT", 10, 0)
+ else
+ row._swatch:SetPoint("LEFT", row._label, "RIGHT", 10, 0)
+ end
+
+ row._removeButton:ClearAllPoints()
+ row._removeButton:SetPoint("LEFT", row._swatch, "RIGHT", 8, 0)
+ row._removeButton:SetSize((item.remove and item.remove.width) or 70, 22)
+ row._removeButton:SetText((item.remove and item.remove.text) or REMOVE or "Remove")
+ row._removeButton:SetScript("OnClick", function()
+ if item.remove and item.remove.onClick then
+ item.remove.onClick(item, row)
+ end
+ end)
+ if row._removeButton.SetEnabled then
+ row._removeButton:SetEnabled(item.remove == nil or item.remove.enabled ~= false)
+ end
+ setSimpleTooltip(row._removeButton, item.remove and item.remove.tooltip)
+
+ row._lsbRefreshing = true
+ for i = 1, #fields do
+ local field = fields[i]
+ local widgets = row._fieldWidgets[i]
+ widgets.slider._lsbMinValue = field.min or 0
+ widgets.slider._lsbMaxValue = field.max or 1
+ widgets.slider._lsbStep = field.step or 1
+ if widgets.slider.SetValue then
+ widgets.slider:SetValue(field.value or 0)
+ end
+ widgets.valueText:SetText(tostring(field.value or 0))
+ end
+ row._lsbRefreshing = nil
+
+ row._swatch:SetColorRGB((color.value and color.value.r) or 1, (color.value and color.value.g) or 1, (color.value and color.value.b) or 1)
+ row._swatch:SetScript("OnClick", function()
+ if color.onClick then
+ color.onClick(item, row)
+ end
+ end)
+ setSimpleTooltip(row._swatch, color.tooltip)
+ if row._swatch.SetEnabled then
+ row._swatch:SetEnabled(color.enabled ~= false)
+ end
+end
+
+local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
+local ACTION_BUTTON_SPACING = 2
+
+local function ensureActionsCollectionRow(row)
+ if row._lsbActionsRow then
+ return
+ end
+
+ row._lsbActionsRow = true
+ row:SetHeight(26)
+ ensureHighlight(row)
+
+ row._icon = row:CreateTexture(nil, "ARTWORK")
+ row._icon:SetPoint("LEFT", 0, 0)
+ row._icon:SetSize(20, 20)
+
+ row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetJustifyH("LEFT")
+ row._label:SetWordWrap(false)
+
+ row._buttons = {}
+ for _, key in ipairs(ACTION_BUTTON_ORDER) do
+ local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ if button.RegisterForClicks then
+ button:RegisterForClicks("LeftButtonDown")
+ end
+ row._buttons[key] = button
+ end
+end
+
+local function refreshActionsCollectionRow(row, item)
+ ensureActionsCollectionRow(row)
+
+ row._label:SetText(item.label or "")
+ setTextureValue(row._icon, item.icon or 134400)
+ applyCollectionRowStyle(row, item)
+ bindCollectionRowTooltip(row, item)
+
+ local anchor = nil
+ for _, key in ipairs(ACTION_BUTTON_ORDER) do
+ local button = row._buttons[key]
+ local action = item.actions and item.actions[key] or nil
+
+ button:ClearAllPoints()
+ button:SetScript("OnClick", nil)
+ button:SetScript("OnEnter", nil)
+ button:SetScript("OnLeave", nil)
+
+ if action and not evaluateStaticOrFunction(action.hidden, action, row, item) then
+ if not anchor then
+ button:SetPoint("RIGHT", row, "RIGHT", -ACTION_BUTTON_SPACING, 0)
+ else
+ button:SetPoint("RIGHT", anchor, "LEFT", -ACTION_BUTTON_SPACING, 0)
+ end
+ button:SetSize(action.width or 26, action.height or 22)
+ local enabled = evaluateStaticOrFunction(action.enabled, action, row, item)
+ if enabled == nil then
+ enabled = true
+ end
+ applyActionButtonTextures(button, action, enabled)
+ if button.SetEnabled then
+ button:SetEnabled(enabled)
+ end
+ setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, row, item))
+ button:SetScript("OnClick", function()
+ if action.onClick then
+ action.onClick(item, row, action)
+ end
+ end)
+ button:Show()
+ anchor = button
+ else
+ button:Hide()
+ end
+ end
+
+ if anchor then
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetPoint("RIGHT", anchor, "LEFT", -6, 0)
+ else
+ row._label:ClearAllPoints()
+ row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
+ row._label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
+ end
+end
+
+local function ensureModeInputRow(row)
+ if row._lsbModeInputRow then
+ return
+ end
+
+ row._lsbModeInputRow = true
+ row:SetHeight(28)
+
+ row._modeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._modeButton:SetPoint("LEFT", row, "LEFT", 0, 0)
+ row._modeButton:SetSize(58, 22)
+
+ row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
+ row._editBox:SetPoint("LEFT", row._modeButton, "RIGHT", 6, 0)
+ row._editBox:SetSize(120, 20)
+ row._editBox:SetAutoFocus(false)
+ if row._editBox.SetNumeric then
+ row._editBox:SetNumeric(true)
+ end
+ if row._editBox.SetMaxLetters then
+ row._editBox:SetMaxLetters(10)
+ end
+ if row._editBox.SetTextInsets then
+ row._editBox:SetTextInsets(6, 6, 0, 0)
+ end
+
+ row._placeholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ row._placeholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0)
+ row._placeholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0)
+ row._placeholder:SetJustifyH("LEFT")
+ row._placeholder:SetWordWrap(false)
+
+ row._previewIcon = row:CreateTexture(nil, "ARTWORK")
+ row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
+ row._previewIcon:SetSize(16, 16)
+ row._previewIcon:Hide()
+
+ row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0)
+ row._previewLabel:SetJustifyH("LEFT")
+ row._previewLabel:SetWordWrap(false)
+ row._previewLabel:Hide()
+
+ row._submitButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ row._submitButton:SetPoint("RIGHT", row, "RIGHT", 0, 0)
+ row._submitButton:SetSize(44, 22)
+ row._submitButton:SetText(ADD or "Add")
+
+ row._previewLabel:SetPoint("RIGHT", row._submitButton, "LEFT", -6, 0)
+
+ row._editBox:SetScript("OnEditFocusGained", function()
+ row._lsbHasFocus = true
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+ row._editBox:SetScript("OnEditFocusLost", function()
+ row._lsbHasFocus = false
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+ row._editBox:SetScript("OnTextChanged", function(self)
+ if row._lsbSyncingText then
+ return
+ end
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onTextChanged then
+ trailer.onTextChanged(self:GetText() or "", trailer, row, row._lsbSectionData)
+ end
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+ row._editBox:SetScript("OnEnterPressed", function()
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onSubmit then
+ local keepFocus = trailer.onSubmit(trailer, row, row._lsbSectionData)
+ if keepFocus then
+ row._editBox:SetFocus()
+ row._editBox:HighlightText()
+ end
+ end
+ end)
+ row._editBox:SetScript("OnTabPressed", function()
+ local trailer = row._lsbTrailerData
+ local keepFocus = nil
+ if trailer and trailer.onTabPressed then
+ keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData)
+ end
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ if keepFocus then
+ row._editBox:SetFocus()
+ row._editBox:HighlightText()
+ end
+ end)
+ row._editBox:SetScript("OnEscapePressed", function(self)
+ local trailer = row._lsbTrailerData
+ if trailer and trailer.onEscapePressed then
+ trailer.onEscapePressed(trailer, row, row._lsbSectionData)
+ end
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ row._lsbHasFocus = false
+ if row._lsbTrailerRefresh then
+ row._lsbTrailerRefresh(row)
+ end
+ end)
+end
+
+local function getModeInputTrailerValue(trailer, key, row, sectionData)
+ return evaluateStaticOrFunction(trailer and trailer[key], trailer, row, sectionData)
+end
+
+local function refreshModeInputRow(row, trailer, sectionData)
+ ensureModeInputRow(row)
+
+ row._lsbTrailerData = trailer
+ row._lsbSectionData = sectionData
+
+ row._lsbTrailerRefresh = function(activeRow)
+ local currentTrailer = activeRow._lsbTrailerData or {}
+ local activeSectionData = activeRow._lsbSectionData
+ local disabled = getModeInputTrailerValue(currentTrailer, "disabled", activeRow, activeSectionData) == true
+ local modeEnabled = getModeInputTrailerValue(currentTrailer, "modeEnabled", activeRow, activeSectionData)
+ local inputEnabled = getModeInputTrailerValue(currentTrailer, "inputEnabled", activeRow, activeSectionData)
+ local submitEnabled = getModeInputTrailerValue(currentTrailer, "submitEnabled", activeRow, activeSectionData)
+ local modeText = getModeInputTrailerValue(currentTrailer, "modeText", activeRow, activeSectionData)
+ local modeTooltip = getModeInputTrailerValue(currentTrailer, "modeTooltip", activeRow, activeSectionData)
+ local text = getModeInputTrailerValue(currentTrailer, "inputText", activeRow, activeSectionData) or ""
+ local placeholder = getModeInputTrailerValue(currentTrailer, "placeholder", activeRow, activeSectionData)
+ local previewIcon = getModeInputTrailerValue(currentTrailer, "previewIcon", activeRow, activeSectionData)
+ local previewText = getModeInputTrailerValue(currentTrailer, "previewText", activeRow, activeSectionData)
+ local submitText = getModeInputTrailerValue(currentTrailer, "submitText", activeRow, activeSectionData)
+ local submitTooltip = getModeInputTrailerValue(currentTrailer, "submitTooltip", activeRow, activeSectionData)
+
+ activeRow._modeButton:SetText(modeText or "")
+ setSimpleTooltip(activeRow._modeButton, modeTooltip)
+ activeRow._modeButton:SetScript("OnClick", function()
+ if currentTrailer.onToggleMode then
+ currentTrailer.onToggleMode(currentTrailer, activeRow, activeRow._lsbSectionData)
+ end
+ if activeRow._lsbTrailerRefresh then
+ activeRow._lsbTrailerRefresh(activeRow)
+ end
+ end)
+ if activeRow._modeButton.SetEnabled then
+ activeRow._modeButton:SetEnabled(not disabled and modeEnabled ~= false)
+ end
+
+ if activeRow._editBox.GetText and activeRow._editBox:GetText() ~= text then
+ activeRow._lsbSyncingText = true
+ activeRow._editBox:SetText(text)
+ activeRow._lsbSyncingText = nil
+ end
+ if activeRow._editBox.SetEnabled then
+ activeRow._editBox:SetEnabled(not disabled and inputEnabled ~= false)
+ end
+
+ activeRow._placeholder:SetText(placeholder or "")
+ if activeRow._lsbHasFocus or text ~= "" then
+ activeRow._placeholder:Hide()
+ else
+ activeRow._placeholder:Show()
+ end
+
+ if previewIcon then
+ setTextureValue(activeRow._previewIcon, previewIcon)
+ activeRow._previewIcon:Show()
+ else
+ setTextureValue(activeRow._previewIcon, nil)
+ activeRow._previewIcon:Hide()
+ end
+
+ if previewText and previewText ~= "" then
+ activeRow._previewLabel:SetText(previewText)
+ activeRow._previewLabel:Show()
+ else
+ activeRow._previewLabel:SetText("")
+ activeRow._previewLabel:Hide()
+ end
+
+ activeRow._submitButton:SetText(submitText or ADD or "Add")
+ setSimpleTooltip(activeRow._submitButton, submitTooltip)
+ activeRow._submitButton:SetScript("OnClick", function()
+ if currentTrailer.onSubmit then
+ local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData)
+ if keepFocus then
+ activeRow._editBox:SetFocus()
+ activeRow._editBox:HighlightText()
+ end
+ end
+ end)
+ if activeRow._submitButton.SetEnabled then
+ activeRow._submitButton:SetEnabled(not disabled and submitEnabled ~= false)
+ end
+ end
+
+ row._lsbTrailerRefresh(row)
+end
+
+local function ensureCollectionContent(frame)
+ if frame._lsbCollectionContent then
+ showFrame(frame._lsbCollectionContent)
+ return frame._lsbCollectionContent
+ end
+
+ local content = CreateFrame("Frame", nil, frame)
+ content:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
+ content:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
+ content:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
+ content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
+ frame._lsbCollectionContent = content
+ return content
+end
+
+local function ensureFlatCollectionWidgets(frame, data)
+ if frame._lsbCollectionScrollBox then
+ showFrame(frame._lsbCollectionScrollBox)
+ showFrame(frame._lsbCollectionScrollBar)
+ return
+ end
+
+ local insetLeft = data.insetLeft or 37
+ local insetTop = data.insetTop or 0
+ local insetBottom = data.insetBottom or 10
+
+ local scrollBox = CreateFrame("Frame", nil, frame, "WowScrollBoxList")
+ scrollBox:SetPoint("TOPLEFT", frame, "TOPLEFT", insetLeft, insetTop)
+ scrollBox:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, insetBottom)
+
+ local scrollBar = CreateFrame("EventFrame", nil, frame, "MinimalScrollBar")
+ scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
+ scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
+
+ local view = CreateScrollBoxListLinearView()
+ view:SetElementExtent(data.rowHeight or 26)
+ view:SetElementInitializer("Frame", function(rowFrame, rowData)
+ local preset = rowData.preset or data.preset
+ if preset == "swatch" then
+ refreshSwatchCollectionRow(rowFrame, rowData.item)
+ elseif preset == "editor" then
+ refreshEditorCollectionRow(rowFrame, rowData.item)
+ end
+ end)
+
+ local dataProvider = CreateDataProvider()
+ ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)
+ scrollBox:SetDataProvider(dataProvider)
+
+ frame._lsbCollectionScrollBox = scrollBox
+ frame._lsbCollectionScrollBar = scrollBar
+ frame._lsbCollectionView = view
+ frame._lsbCollectionDataProvider = dataProvider
+end
+
+local function refreshFlatCollection(frame, data)
+ ensureFlatCollectionWidgets(frame, data)
+
+ local scrollBox = frame._lsbCollectionScrollBox
+ local dataProvider = frame._lsbCollectionDataProvider
+ local items = data.items and data.items(frame) or {}
+
+ if dataProvider and dataProvider.Flush then
+ dataProvider:Flush()
+ end
+
+ for _, item in ipairs(items or {}) do
+ if dataProvider and dataProvider.Insert then
+ dataProvider:Insert({
+ preset = data.preset,
+ item = item,
+ })
+ end
+ end
+
+ if scrollBox and scrollBox.SetDataProvider then
+ scrollBox:SetDataProvider(dataProvider)
+ end
+end
+
+local function ensureSectionHeaderRow(content, headers, sectionKey, title)
+ local row = headers[sectionKey]
+ if row then
+ return row
+ end
+
+ row = CreateFrame("Frame", nil, content)
+ row:SetHeight(28)
+ row._title = lib.CreateSubheaderTitle(row, title)
+ headers[sectionKey] = row
+ return row
+end
+
+local function ensureSectionEmptyLabel(content, labels, sectionKey)
+ local label = labels[sectionKey]
+ if label then
+ return label
+ end
+
+ label = content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
+ label:SetJustifyH("LEFT")
+ labels[sectionKey] = label
+ return label
+end
+
+local function refreshSectionedCollection(frame, data)
+ local content = ensureCollectionContent(frame)
+ local sections = data.sections and data.sections(frame) or {}
+ local headers = frame._lsbSectionHeaders or {}
+ local rowPools = frame._lsbSectionRowPools or {}
+ local emptyLabels = frame._lsbSectionEmptyLabels or {}
+ local trailerRows = frame._lsbSectionTrailerRows or {}
+ local y = 0
+ local insetLeft = data.insetLeft or 37
+ local insetRight = data.insetRight or 20
+ local rowSpacing = data.rowSpacing or 4
+ local sectionSpacing = data.sectionSpacing or 12
+
+ frame._lsbSectionHeaders = headers
+ frame._lsbSectionRowPools = rowPools
+ frame._lsbSectionEmptyLabels = emptyLabels
+ frame._lsbSectionTrailerRows = trailerRows
+
+ for _, pool in pairs(rowPools) do
+ for _, row in ipairs(pool) do
+ row:Hide()
+ end
+ end
+ for _, row in pairs(headers) do
+ row:Hide()
+ end
+ for _, label in pairs(emptyLabels) do
+ label:Hide()
+ end
+ for _, trailer in pairs(trailerRows) do
+ trailer:Hide()
+ end
+
+ for _, section in ipairs(sections) do
+ local sectionKey = section.key or section.name or tostring(_)
+ local header = ensureSectionHeaderRow(content, headers, sectionKey, section.title or section.name or "")
+ header._title:SetText(section.title or section.name or "")
+ header:ClearAllPoints()
+ header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
+ header:SetPoint("RIGHT", content, "RIGHT", 0, 0)
+ header:Show()
+ y = y - (section.headerHeight or 28)
+
+ local items = section.items or {}
+ local pool = rowPools[sectionKey] or {}
+ rowPools[sectionKey] = pool
+
+ if #items == 0 and section.emptyText then
+ local label = ensureSectionEmptyLabel(content, emptyLabels, sectionKey)
+ label:SetText(section.emptyText)
+ label:ClearAllPoints()
+ label:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ label:Show()
+ y = y - ((section.emptyHeight or 26) + rowSpacing)
+ end
+
+ for index, item in ipairs(items) do
+ local row = pool[index]
+ if not row then
+ row = CreateFrame("Frame", nil, content)
+ pool[index] = row
+ end
+
+ refreshActionsCollectionRow(row, item)
+ row:ClearAllPoints()
+ row:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ row:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
+ row:Show()
+ y = y - ((section.rowHeight or 26) + rowSpacing)
+ end
+
+ local footer = section.footer
+ local footerType = footer and (footer.type or footer.preset)
+ if footerType == "modeInput" then
+ local trailerRow = trailerRows[sectionKey]
+ if not trailerRow then
+ trailerRow = CreateFrame("Frame", nil, content)
+ trailerRows[sectionKey] = trailerRow
+ end
+
+ footer.preset = footer.preset or footerType
+ refreshModeInputRow(trailerRow, footer, section)
+ trailerRow:ClearAllPoints()
+ trailerRow:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
+ trailerRow:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
+ trailerRow:Show()
+ y = y - (section.footerHeight or 28)
+ end
+
+ y = y - (section.spacingAfter or sectionSpacing)
+ end
+end
+
+local function applyCollectionFrame(frame, data, initializer)
+ frame.OnDefault = data.onDefault
+ frame._lsbCollectionData = data
+ frame._lsbCollectionInitializer = initializer
+
+ if data.sections then
+ refreshSectionedCollection(frame, data)
+ else
+ refreshFlatCollection(frame, data)
+ end
+end
+
+internal.applyCollectionFrame = applyCollectionFrame
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
new file mode 100644
index 00000000..45000c4c
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -0,0 +1,65 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local applyCollectionFrame = internal.applyCollectionFrame
+local createCustomListRowInitializer = internal.createCustomListRowInitializer
+local copyMixin = internal.copyMixin
+
+function lib._installStandardCollectionControls(SB, env)
+ local applyCanvasState = env.applyCanvasState
+ local applyModifiers = env.applyModifiers
+ local registerCategoryRefreshable = env.registerCategoryRefreshable
+ local resolveCategory = env.resolveCategory
+
+ local function createCollectionInitializer(spec, errorPrefix)
+ assert(spec.height, errorPrefix .. ": spec.height is required")
+ local cat = resolveCategory(spec)
+ local data = copyMixin({}, spec)
+ if data.variant and data.preset == nil then
+ data.preset = data.variant
+ end
+
+ local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
+
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ local activeFrame = controlInitializer._lsbActiveFrame
+ if activeFrame then
+ applyCanvasState(activeFrame, enabled)
+ end
+ end
+
+ initializer._lsbRefreshFrame = function(frame)
+ applyCollectionFrame(frame, data, initializer)
+ initializer:SetEnabled(initializer._lsbEnabled ~= false)
+ end
+
+ Settings.RegisterInitializer(cat, initializer)
+ registerCategoryRefreshable(cat, initializer)
+ applyModifiers(initializer, spec)
+
+ return initializer
+ end
+
+ function SB.List(spec)
+ assert(spec.items, "List: spec.items is required")
+ assert(not spec.sections, "List: spec.sections is not supported")
+ return createCollectionInitializer(spec, "List")
+ end
+
+ function SB.SectionList(spec)
+ assert(spec.sections, "SectionList: spec.sections is required")
+ return createCollectionInitializer(spec, "SectionList")
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
new file mode 100644
index 00000000..0f6e83d8
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -0,0 +1,141 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local applyEmbedCanvasFrame = internal.applyEmbedCanvasFrame
+local applyHeaderFrame = internal.applyHeaderFrame
+local applyInfoRowFrame = internal.applyInfoRowFrame
+local applySubheaderFrame = internal.applySubheaderFrame
+local copyMixin = internal.copyMixin
+local createCustomListRowInitializer = internal.createCustomListRowInitializer
+local hideHeaderActionButtons = internal.hideHeaderActionButtons
+
+function lib._installStandardRowControls(SB, env, config)
+ local applyModifiers = env.applyModifiers
+ local registerCategoryRefreshable = env.registerCategoryRefreshable
+ local resolveCategory = env.resolveCategory
+
+ local function addLayoutInitializer(spec, initializer, refreshable)
+ local cat = resolveCategory(spec)
+ SB._layouts[cat]:AddInitializer(initializer)
+ if refreshable then
+ registerCategoryRefreshable(cat, initializer)
+ end
+ applyModifiers(initializer, spec)
+ return initializer, cat
+ end
+
+ function SB.Header(textOrSpec, category)
+ local spec
+ if type(textOrSpec) == "table" then
+ spec = textOrSpec
+ else
+ spec = {
+ name = textOrSpec,
+ category = category,
+ }
+ end
+
+ assert(not spec.actions, "Header: use PageActions for category header buttons")
+ local initializer = CreateSettingsListSectionHeaderInitializer(spec.name)
+ return addLayoutInitializer(spec, initializer)
+ end
+
+ function SB.PageActions(spec)
+ assert(spec.actions, "PageActions: spec.actions is required")
+
+ local cat = resolveCategory(spec)
+ local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName) or ""
+ local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ _lsbKind = "pageActions",
+ name = spec.name or catName,
+ actions = spec.actions,
+ hideTitle = true,
+ attachToCategoryHeader = true,
+ }, spec.height or 1, applyHeaderFrame)
+ initializer._lsbRefreshFrame = function(frame)
+ applyHeaderFrame(frame, initializer:GetData())
+ end
+ initializer._lsbResetFrame = hideHeaderActionButtons
+ return addLayoutInitializer(spec, initializer, true)
+ end
+
+ function SB.Subheader(spec)
+ local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ _lsbKind = "subheader",
+ name = spec.name,
+ }, 28, applySubheaderFrame)
+ return addLayoutInitializer(spec, initializer)
+ end
+
+ function SB.InfoRow(spec)
+ local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, {
+ _lsbKind = "infoRow",
+ name = spec.name,
+ value = spec.value,
+ wide = spec.wide,
+ multiline = spec.multiline,
+ }, spec.height or 26, applyInfoRowFrame)
+ initializer._lsbRefreshFrame = function(frame)
+ applyInfoRowFrame(frame, initializer:GetData())
+ end
+ return addLayoutInitializer(spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
+ end
+
+ function SB.EmbedCanvas(canvas, height, spec)
+ spec = spec or {}
+ local cat = spec.category or SB._currentSubcategory or SB._rootCategory
+
+ local modifiers = copyMixin({}, spec)
+ modifiers.canvas = canvas
+
+ local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, {
+ _lsbKind = "embedCanvas",
+ canvas = canvas,
+ }, height or canvas:GetHeight(), applyEmbedCanvasFrame)
+
+ Settings.RegisterInitializer(cat, initializer)
+ applyModifiers(initializer, modifiers)
+
+ return initializer
+ end
+
+ local confirmDialogName = config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm"
+ StaticPopupDialogs[confirmDialogName] = {
+ text = "%s",
+ button1 = YES,
+ button2 = NO,
+ OnAccept = function(_, data)
+ if data and data.onAccept then
+ data.onAccept()
+ end
+ end,
+ timeout = 0,
+ whileDead = true,
+ hideOnEscape = true,
+ }
+
+ function SB.Button(spec)
+ local onClick = spec.onClick
+ if spec.confirm then
+ local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
+ local originalClick = onClick
+ onClick = function()
+ StaticPopup_Show(confirmDialogName, confirmText, nil, { onAccept = originalClick })
+ end
+ end
+
+ local initializer =
+ CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true)
+ return addLayoutInitializer(spec, initializer)
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
new file mode 100644
index 00000000..7dd39422
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -0,0 +1,1066 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+-- LibSettingsBuilder: A standalone path-based settings builder for the
+-- World of Warcraft Settings API. Provides proxy controls, composite groups
+-- and utility helpers.
+
+local MAJOR, MINOR = "LibSettingsBuilder-1.0", 3
+local lib = LibStub:NewLibrary(MAJOR, MINOR)
+if not lib then
+ return
+end
+
+lib._loadState = { open = true }
+lib._internal = {}
+
+local internal = lib._internal
+
+lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
+lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
+lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
+lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
+lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
+
+lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
+lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
+
+--- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide
+--- callbacks registered via RegisterPage. Defers automatically if
+--- SettingsPanel has not been created yet (Blizzard_Settings loads on demand).
+local function installPageLifecycleHooks()
+ if lib._pageLifecycleHooked then
+ return
+ end
+
+ if type(SettingsPanel) ~= "table" or type(SettingsPanel.DisplayCategory) ~= "function" then
+ -- SettingsPanel not yet loaded; listen for ADDON_LOADED to retry.
+ if lib._pageLifecycleDeferred or type(CreateFrame) ~= "function" then
+ return
+ end
+ lib._pageLifecycleDeferred = true
+ local f = CreateFrame("Frame")
+ f:RegisterEvent("ADDON_LOADED")
+ f:SetScript("OnEvent", function(self)
+ if type(SettingsPanel) == "table" and type(SettingsPanel.DisplayCategory) == "function" then
+ self:UnregisterAllEvents()
+ installPageLifecycleHooks()
+ end
+ end)
+ return
+ end
+
+ lib._pageLifecycleHooked = true
+
+ -- DisplayCategory fires for both sidebar clicks and OpenToCategory.
+ -- Retrieve the active category via GetCurrentCategory inside the hook.
+ hooksecurefunc(SettingsPanel, "DisplayCategory", function(panel)
+ local category = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil
+ local old = lib._activeLifecycleCategory
+ if old == category then
+ return
+ end
+
+ if old then
+ local cbs = lib._pageLifecycleCallbacks[old]
+ if cbs and cbs.onHide then
+ cbs.onHide()
+ end
+ end
+
+ lib._activeLifecycleCategory = category
+ if category then
+ local cbs = lib._pageLifecycleCallbacks[category]
+ if cbs and cbs.onShow then
+ cbs.onShow()
+ end
+ end
+ end)
+
+ SettingsPanel:HookScript("OnHide", function()
+ local active = lib._activeLifecycleCategory
+ if active then
+ local cbs = lib._pageLifecycleCallbacks[active]
+ if cbs and cbs.onHide then
+ cbs.onHide()
+ end
+ end
+ lib._activeLifecycleCategory = nil
+ end)
+end
+
+local function copyMixin(target, source)
+ for key, value in pairs(source) do
+ target[key] = value
+ end
+ return target
+end
+
+local function setInitializerExtent(initializer, extent)
+ if initializer.SetExtent then
+ return initializer:SetExtent(extent)
+ end
+ initializer.GetExtent = function()
+ return extent
+ end
+end
+
+local function getInitializerData(initializer)
+ return initializer and (initializer._lsbData or (initializer.GetData and initializer:GetData())) or nil
+end
+
+local function getSettingVariable(setting)
+ return setting and (setting._lsbVariable or setting._variable)
+end
+
+local function registerValueChangedCallback(frame, variable, callback, owner)
+ local handles = frame and frame.cbrHandles
+ if variable and handles and handles.SetOnValueChangedCallback then
+ handles:SetOnValueChangedCallback(variable, callback, owner or frame)
+ end
+end
+
+local function makeStableSortKey(value)
+ local valueType = type(value)
+ if valueType == "number" then
+ return "1:" .. string.format("%020.10f", value)
+ end
+ if valueType == "boolean" then
+ return value and "2:true" or "2:false"
+ end
+ return valueType .. ":" .. tostring(value):lower()
+end
+
+local function getOrderedValueEntries(values)
+ local entries = {}
+ if not values then
+ return entries
+ end
+
+ for value, label in pairs(values) do
+ entries[#entries + 1] = {
+ value = value,
+ label = label,
+ labelSortKey = tostring(label):lower(),
+ valueSortKey = makeStableSortKey(value),
+ }
+ end
+
+ table.sort(entries, function(left, right)
+ if left.labelSortKey == right.labelSortKey then
+ return left.valueSortKey < right.valueSortKey
+ end
+ return left.labelSortKey < right.labelSortKey
+ end)
+
+ return entries
+end
+
+local function showFrame(frame)
+ if frame and frame.Show then
+ frame:Show()
+ end
+end
+
+local function setTextureValue(texture, value)
+ if not texture or not texture.SetTexture then
+ return
+ end
+
+ if value == nil then
+ texture:SetTexture(nil)
+ return
+ end
+
+ if type(value) == "number" and texture.SetToFileData then
+ texture:SetToFileData(value)
+ return
+ end
+
+ texture:SetTexture(value)
+end
+
+local DEFAULT_ACTION_BUTTON_HIGHLIGHT = "Interface\\Buttons\\ButtonHilight-Square"
+local DEFAULT_ACTION_BUTTON_DISABLED_ALPHA = 0.4
+local DEFAULT_SWATCH_CENTER_X = -73
+
+local function getButtonTextureValue(button, getterName)
+ local getter = button and button[getterName]
+ if type(getter) ~= "function" then
+ return nil
+ end
+
+ local texture = getter(button)
+ if texture and texture.GetTexture then
+ return texture:GetTexture()
+ end
+
+ return texture
+end
+
+local function ensureActionButtonTextureDefaults(button)
+ if button._lsbActionButtonTextureDefaults then
+ return button._lsbActionButtonTextureDefaults
+ end
+
+ local defaults = {
+ disabled = getButtonTextureValue(button, "GetDisabledTexture"),
+ highlight = getButtonTextureValue(button, "GetHighlightTexture"),
+ normal = getButtonTextureValue(button, "GetNormalTexture"),
+ pushed = getButtonTextureValue(button, "GetPushedTexture"),
+ }
+
+ button._lsbActionButtonTextureDefaults = defaults
+ return defaults
+end
+
+local function setButtonTextureState(button, setterName, getterName, value, blendMode, alpha)
+ local setter = button and button[setterName]
+ if type(setter) ~= "function" then
+ return
+ end
+
+ if blendMode ~= nil then
+ setter(button, value, blendMode)
+ else
+ setter(button, value)
+ end
+
+ local getter = button and button[getterName]
+ if type(getter) ~= "function" then
+ return
+ end
+
+ local texture = getter(button)
+ if not texture then
+ return
+ end
+
+ if texture.ClearAllPoints then
+ texture:ClearAllPoints()
+ end
+ if texture.SetAllPoints then
+ texture:SetAllPoints(button)
+ end
+ if alpha ~= nil and texture.SetAlpha then
+ texture:SetAlpha(alpha)
+ end
+end
+
+local function applyActionButtonTextures(button, action, enabled)
+ if not button then
+ return
+ end
+
+ local defaults = ensureActionButtonTextureDefaults(button)
+ local textures = action and action.buttonTextures
+
+ if button.SetText then
+ button:SetText(textures and textures.normal and "" or (action and action.text or ""))
+ end
+
+ if textures and textures.normal then
+ setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", textures.normal)
+ setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", textures.pushed or textures.normal)
+ setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", textures.disabled or textures.normal)
+
+ local highlight = textures.highlight
+ if highlight == nil then
+ highlight = DEFAULT_ACTION_BUTTON_HIGHLIGHT
+ end
+ setButtonTextureState(
+ button,
+ "SetHighlightTexture",
+ "GetHighlightTexture",
+ highlight,
+ highlight and "ADD" or nil,
+ textures.highlightAlpha or 0.25
+ )
+
+ if button.SetAlpha then
+ button:SetAlpha(enabled == false and (textures.disabledAlpha or DEFAULT_ACTION_BUTTON_DISABLED_ALPHA) or 1)
+ end
+
+ button._lsbUsesActionButtonTextures = true
+ return
+ end
+
+ if button._lsbUsesActionButtonTextures then
+ setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", defaults.normal)
+ setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", defaults.pushed)
+ setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", defaults.disabled)
+ setButtonTextureState(button, "SetHighlightTexture", "GetHighlightTexture", defaults.highlight)
+ button._lsbUsesActionButtonTextures = nil
+ end
+
+ if button.SetAlpha then
+ button:SetAlpha(1)
+ end
+end
+
+local function setGameTooltipText(text, wrap)
+ GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true)
+end
+
+local function setSimpleTooltip(owner, text)
+ if not owner or not owner.SetScript then
+ return
+ end
+
+ owner:SetScript("OnEnter", nil)
+ owner:SetScript("OnLeave", nil)
+
+ if not text or text == "" then
+ return
+ end
+
+ owner:SetScript("OnEnter", function(self)
+ if not GameTooltip then
+ return
+ end
+
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ setGameTooltipText(text, true)
+ GameTooltip:Show()
+ end)
+ owner:SetScript("OnLeave", function()
+ if GameTooltip_Hide then
+ GameTooltip_Hide()
+ end
+ end)
+end
+
+local function evaluateStaticOrFunction(value, ...)
+ if type(value) == "function" then
+ return value(...)
+ end
+ return value
+end
+
+local function createTitle(parent, template, x, y, text, fontObject)
+ local title = parent:CreateFontString(nil, "OVERLAY", template)
+ title:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
+ title:SetJustifyH("LEFT")
+ title:SetJustifyV("TOP")
+ if fontObject then
+ title:SetFontObject(fontObject)
+ end
+ if text ~= nil then
+ title:SetText(text)
+ end
+ title:Show()
+ return title
+end
+
+local function createSubheaderTitle(parent, text)
+ return createTitle(parent, "GameFontHighlightSmall", 35, -8, text, GameFontHighlight)
+end
+
+local function createHeaderTitle(parent, text)
+ return createTitle(parent, "GameFontHighlightLarge", 7, -16, text)
+end
+
+lib.CreateHeaderTitle = createHeaderTitle
+lib.CreateSubheaderTitle = createSubheaderTitle
+
+--------------------------------------------------------------------------------
+-- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
+-- Replicates Blizzard's Settings panel positioning so canvas pages are
+-- visually indistinguishable from vertical-layout pages.
+--
+-- Measurements from Blizzard_SettingControls.xml/.lua:
+-- Element height: 26 (all control types)
+-- Section header: 45 (GameFontHighlightLarge at TOPLEFT 7, -16)
+-- Label left offset: indent + 37
+-- Label right bound: CENTER - 85
+-- Control anchor: CENTER - 80 (checkbox, slider, color swatch)
+-- Button anchor: CENTER - 40 (width 200)
+-- Indent per level: 15
+--------------------------------------------------------------------------------
+
+lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults
+ or {
+ elementHeight = 26,
+ headerHeight = 50,
+ labelX = 37,
+ controlCenterX = -80,
+ buttonCenterX = -40,
+ buttonWidth = 200,
+ sliderWidth = 250,
+ swatchCenterX = DEFAULT_SWATCH_CENTER_X,
+ verifiedPatch = "Retail 12.0/12.1",
+ }
+
+local CanvasLayout = {}
+lib.CanvasLayout = CanvasLayout
+
+local function getCanvasLayoutMetrics(layout)
+ return layout._metrics or lib.CanvasLayoutDefaults
+end
+
+function CanvasLayout:_Advance(h)
+ self.yPos = self.yPos - h
+end
+
+function CanvasLayout:_CreateRow(h)
+ local metrics = getCanvasLayoutMetrics(self)
+ h = h or metrics.elementHeight
+ local row = CreateFrame("Frame", nil, self.frame)
+ row:SetPoint("TOPLEFT", 0, self.yPos)
+ row:SetPoint("RIGHT")
+ row:SetHeight(h)
+ self.elements[#self.elements + 1] = row
+ self:_Advance(h)
+ return row
+end
+
+function CanvasLayout:_AddLabel(row, text, fontObject)
+ local metrics = getCanvasLayoutMetrics(self)
+ local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal")
+ label:SetPoint("LEFT", metrics.labelX, 0)
+ label:SetPoint("RIGHT", row, "CENTER", -85, 0)
+ label:SetJustifyH("LEFT")
+ label:SetWordWrap(false)
+ label:SetText(text)
+ row._label = label
+ return label
+end
+
+--- Add a page header using Blizzard's SettingsListTemplate.Header.
+--- Provides Title, Options_HorizontalDivider, and DefaultsButton.
+---@return Frame row (row._title, row._defaultsButton exposed)
+function CanvasLayout:AddHeader(text)
+ local metrics = getCanvasLayoutMetrics(self)
+ local row = self:_CreateRow(metrics.headerHeight)
+ local settingsList = CreateFrame("Frame", nil, row, "SettingsListTemplate")
+ settingsList:SetAllPoints(row)
+ settingsList.ScrollBox:Hide()
+ settingsList.ScrollBar:Hide()
+ settingsList.Header.Title:SetText(text)
+ row._title = settingsList.Header.Title
+ row._defaultsButton = settingsList.Header.DefaultsButton
+ return row
+end
+
+--- Add vertical spacing.
+function CanvasLayout:AddSpacer(height)
+ self:_Advance(height)
+end
+
+--- Add a description / informational text row.
+function CanvasLayout:AddDescription(text, fontObject)
+ local metrics = getCanvasLayoutMetrics(self)
+ local row = self:_CreateRow()
+ local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal")
+ label:SetPoint("LEFT", metrics.labelX, 0)
+ label:SetPoint("RIGHT", row, "RIGHT", -10, 0)
+ label:SetJustifyH("LEFT")
+ label:SetText(text)
+ row._text = label
+ return row
+end
+
+--- Add a color swatch row (label + clickable swatch).
+---@return Frame row, Button swatch
+function CanvasLayout:AddColorSwatch(labelText)
+ local metrics = getCanvasLayoutMetrics(self)
+ local row = self:_CreateRow()
+ self:_AddLabel(row, labelText)
+ local swatch = lib.CreateColorSwatch(row)
+ swatch:SetPoint("LEFT", row, "CENTER", metrics.swatchCenterX, 0)
+ row._swatch = swatch
+ return row, swatch
+end
+
+--- Add a slider row (label + MinimalSliderWithSteppers).
+---@return Frame row, Slider slider, FontString valueText
+function CanvasLayout:AddSlider(labelText, min, max, step)
+ local metrics = getCanvasLayoutMetrics(self)
+ local row = self:_CreateRow()
+ self:_AddLabel(row, labelText)
+ local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
+ slider:SetWidth(metrics.sliderWidth)
+ slider:SetPoint("LEFT", row, "CENTER", metrics.controlCenterX, 3)
+ slider:SetMinMaxValues(min, max)
+ slider:SetValueStep(step or 1)
+ slider:SetObeyStepOnDrag(true)
+ local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ valueText:SetPoint("LEFT", slider, "RIGHT", 8, 0)
+ valueText:SetWidth(40)
+ valueText:SetJustifyH("LEFT")
+ row._slider = slider
+ row._valueText = valueText
+ return row, slider, valueText
+end
+
+--- Add a button row (label + UIPanelButton).
+---@return Frame row, Button button
+function CanvasLayout:AddButton(labelText, buttonText)
+ local metrics = getCanvasLayoutMetrics(self)
+ local row = self:_CreateRow()
+ self:_AddLabel(row, labelText)
+ local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ button:SetSize(metrics.buttonWidth, 26)
+ button:SetPoint("LEFT", row, "CENTER", metrics.buttonCenterX, 0)
+ button:SetText(buttonText)
+ row._button = button
+ return row, button
+end
+
+--- Add a scroll list that fills the remaining vertical space.
+---@return Frame scrollBox, EventFrame scrollBar, table view
+function CanvasLayout:AddScrollList(elementExtent)
+ local metrics = getCanvasLayoutMetrics(self)
+ local scrollBox = CreateFrame("Frame", nil, self.frame, "WowScrollBoxList")
+ scrollBox:SetPoint("TOPLEFT", metrics.labelX, self.yPos)
+ scrollBox:SetPoint("BOTTOMRIGHT", -30, 10)
+ local scrollBar = CreateFrame("EventFrame", nil, self.frame, "MinimalScrollBar")
+ scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
+ scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
+ local view = CreateScrollBoxListLinearView()
+ view:SetElementExtent(elementExtent)
+ ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)
+ return scrollBox, scrollBar, view
+end
+
+--------------------------------------------------------------------------------
+-- Static utilities (shared across all instances)
+--------------------------------------------------------------------------------
+
+--- Create a color swatch button using Blizzard's SettingsColorSwatchTemplate.
+--- Inherits ColorSwatchTemplate (SwatchBg/InnerBorder/Color layers) and
+--- SettingsColorSwatchMixin (hover effects, color picker integration).
+---@param parent Frame
+---@return Button swatch (swatch._tex points to swatch.Color for backward compat)
+function lib.CreateColorSwatch(parent)
+ local swatch = CreateFrame("Button", nil, parent, "SettingsColorSwatchTemplate")
+ swatch._tex = swatch.Color
+ if swatch.EnableMouse then
+ swatch:EnableMouse(true)
+ end
+ if swatch.RegisterForClicks then
+ swatch:RegisterForClicks("LeftButtonUp", "RightButtonUp")
+ end
+ return swatch
+end
+
+--------------------------------------------------------------------------------
+-- Path accessors: built-in dot-path resolution with numeric key support
+--------------------------------------------------------------------------------
+
+local function defaultGetNestedValue(tbl, path)
+ local current = tbl
+ for segment in path:gmatch("[^.]+") do
+ if type(current) ~= "table" then
+ return nil
+ end
+ local val = current[segment]
+ if val == nil then
+ local num = tonumber(segment)
+ if num then
+ val = current[num]
+ end
+ end
+ current = val
+ end
+ return current
+end
+
+local function defaultSetNestedValue(tbl, path, value)
+ local current, lastKey = tbl, nil
+ for segment in path:gmatch("[^.]+") do
+ if lastKey then
+ local resolved = lastKey
+ if current[lastKey] == nil then
+ local num = tonumber(lastKey)
+ if num and current[num] ~= nil then
+ resolved = num
+ end
+ end
+ if current[resolved] == nil then
+ current[resolved] = {}
+ end
+ current = current[resolved]
+ end
+ lastKey = segment
+ end
+ local resolved = lastKey
+ if current[lastKey] == nil then
+ local num = tonumber(lastKey)
+ if num then
+ resolved = num
+ end
+ end
+ current[resolved] = value
+end
+
+--- Creates a path adapter for resolving dot-delimited paths to get/set/default
+--- bindings. Built-in accessors handle numeric path segments (e.g. "colors.0").
+---@param config table
+--- Required: getStore (function() -> table), getDefaults (function() -> table)
+--- Optional: getNestedValue, setNestedValue (custom path accessors)
+---@return table adapter with :resolve(path) and :read(path) methods
+function lib.PathAdapter(config)
+ assert(config.getStore, "PathAdapter: getStore is required")
+ assert(config.getDefaults, "PathAdapter: getDefaults is required")
+
+ local getNested = config.getNestedValue or defaultGetNestedValue
+ local setNested = config.setNestedValue or defaultSetNestedValue
+
+ return {
+ resolve = function(self, path)
+ return {
+ get = function()
+ return getNested(config.getStore(), path)
+ end,
+ set = function(value)
+ setNested(config.getStore(), path, value)
+ end,
+ default = getNested(config.getDefaults(), path),
+ }
+ end,
+ read = function(self, path)
+ return getNested(config.getStore(), path)
+ end,
+ }
+end
+
+--- Create a new SettingsBuilder instance.
+---@param config table
+--- Required fields:
+--- varPrefix string e.g. "ECM"
+--- onChanged function(spec, value) called after each setter
+--- Optional fields:
+--- pathAdapter table PathAdapter instance for path-based controls
+--- compositeDefaults table keyed by composite function name
+---@return table builder instance with the full SB API
+function lib:New(config)
+ assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required")
+ assert(config.onChanged, "LibSettingsBuilder: onChanged is required")
+
+ local SB = {}
+ SB._rootCategory = nil
+ SB._rootCategoryName = nil
+ SB._currentSubcategory = nil
+ SB._subcategories = {}
+ SB._subcategoryNames = {}
+ SB._layouts = {}
+ SB._reactiveControls = {}
+ SB._categoryRefreshables = {}
+
+ SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
+ SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
+ SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
+ SB.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
+ SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
+ SB.CreateHeaderTitle = lib.CreateHeaderTitle
+ SB.CreateSubheaderTitle = lib.CreateSubheaderTitle
+ SB.CreateColorSwatch = lib.CreateColorSwatch
+
+ local function defaultSliderFormatter(value)
+ return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value)
+ end
+
+ local adapter = config.pathAdapter
+
+ local function makeVarNameFromIdentifier(identifier)
+ return config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
+ end
+
+ local function makeVarName(spec)
+ local id = spec.key or spec.path
+ return makeVarNameFromIdentifier(id)
+ end
+
+ local function resolveCategory(spec)
+ return spec.category or SB._currentSubcategory or SB._rootCategory
+ end
+
+ local function registerCategoryRefreshable(category, initializer)
+ if not category or not initializer then
+ return
+ end
+
+ local refreshables = SB._categoryRefreshables[category]
+ if not refreshables then
+ refreshables = {}
+ SB._categoryRefreshables[category] = refreshables
+ end
+
+ for _, existing in ipairs(refreshables) do
+ if existing == initializer then
+ return
+ end
+ end
+
+ refreshables[#refreshables + 1] = initializer
+ end
+
+ local reevaluateReactiveControls
+ local setCanvasInteractive
+
+ local function postSet(spec, value, setting)
+ if spec.onSet then
+ spec.onSet(value, setting)
+ end
+ config.onChanged(spec, value)
+ reevaluateReactiveControls()
+ end
+
+ --- Resolves a spec into a binding with get/set/default.
+ --- Handler mode: spec provides explicit get, set, key, and default.
+ --- Path mode: spec provides a path string; the pathAdapter generates get/set/default.
+ local function resolveBinding(spec)
+ local hasPath = spec.path ~= nil
+ local hasHandler = spec.get ~= nil or spec.set ~= nil
+
+ assert(not (hasPath and hasHandler), "spec cannot have both path and get/set")
+
+ if hasHandler then
+ assert(spec.get, "handler mode requires get")
+ assert(spec.set, "handler mode requires set")
+ assert(spec.key, "handler mode requires key")
+ return { get = spec.get, set = spec.set, default = spec.default }
+ end
+
+ assert(hasPath, "spec must have either path or get/set")
+ assert(adapter, "path mode requires a pathAdapter on the builder")
+
+ local binding = adapter:resolve(spec.path)
+ if spec.default ~= nil then
+ binding.default = spec.default
+ end
+ return binding
+ end
+
+ --- Consolidates the getter/setter/default/transform/register boilerplate
+ --- shared by Checkbox, Slider, Dropdown, and Custom.
+ local function makeProxySetting(spec, varType, defaultFallback, binding)
+ local variable = makeVarName(spec)
+ local cat = resolveCategory(spec)
+ local setting
+
+ binding = binding or resolveBinding(spec)
+
+ local function getter()
+ local val = binding.get()
+ if spec.getTransform then
+ val = spec.getTransform(val)
+ end
+ return val
+ end
+
+ local function applyValue(value)
+ if spec.setTransform then
+ value = spec.setTransform(value)
+ end
+ binding.set(value)
+ return value
+ end
+
+ local function setter(value)
+ value = applyValue(value)
+ postSet(spec, value, setting)
+ end
+
+ local function setValueNoCallback(_, value)
+ value = applyValue(value)
+ config.onChanged(spec, value)
+ reevaluateReactiveControls()
+ end
+
+ local default = binding.default
+ if spec.getTransform then
+ default = spec.getTransform(default)
+ end
+
+ if default == nil then
+ default = defaultFallback
+ end
+
+ setting = Settings.RegisterProxySetting(cat, variable, varType, spec.name, default, getter, setter)
+ setting.SetValueNoCallback = setValueNoCallback
+ setting._lsbVariable = variable
+
+ return setting, cat
+ end
+
+ --- Copies inherited modifier keys from a composite spec onto a child spec
+ --- when the child hasn't set them explicitly.
+ local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" }
+ local function propagateModifiers(target, source)
+ for _, key in ipairs(MODIFIER_KEYS) do
+ if target[key] == nil then
+ target[key] = source[key]
+ end
+ end
+ end
+
+ --- Merges compositeDefaults for the given composite function name onto spec.
+ --- Spec values win over defaults.
+ local function mergeCompositeDefaults(functionName, spec)
+ local defaults = config.compositeDefaults and config.compositeDefaults[functionName]
+ if not defaults then
+ return spec or {}
+ end
+ return spec and copyMixin(copyMixin({}, defaults), spec) or copyMixin({}, defaults)
+ end
+
+ ----------------------------------------------------------------------------
+ -- Debug spec validation (active only when LSB_DEBUG is truthy)
+ ----------------------------------------------------------------------------
+
+ local COMMON_SPEC_FIELDS = {
+ path = true,
+ name = true,
+ tooltip = true,
+ category = true,
+ onSet = true,
+ getTransform = true,
+ setTransform = true,
+ parent = true,
+ parentCheck = true,
+ disabled = true,
+ hidden = true,
+ layout = true,
+ type = true,
+ desc = true,
+ get = true,
+ set = true,
+ key = true,
+ default = true,
+ }
+
+ local EXTRA_FIELDS_BY_TYPE = {
+ checkbox = {},
+ slider = { min = true, max = true, step = true, formatter = true },
+ dropdown = { values = true, scrollHeight = true },
+ color = {},
+ input = {
+ debounce = true,
+ maxLetters = true,
+ numeric = true,
+ onTextChanged = true,
+ resolveText = true,
+ watch = true,
+ watchVariables = true,
+ width = true,
+ },
+ custom = { template = true, varType = true },
+ }
+
+ local function validateSpecFields(controlType, spec)
+ if not LSB_DEBUG then
+ return
+ end
+ local allowed = EXTRA_FIELDS_BY_TYPE[controlType]
+ if not allowed then
+ return
+ end
+ for key in pairs(spec) do
+ if not COMMON_SPEC_FIELDS[key] and not allowed[key] then
+ print(
+ "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '"
+ .. tostring(key)
+ .. "' on "
+ .. controlType
+ .. " control '"
+ .. tostring(spec.name or spec.path)
+ .. "'"
+ )
+ end
+ end
+ end
+
+ setCanvasInteractive = function(frame, enabled)
+ if frame.SetEnabled then
+ frame:SetEnabled(enabled)
+ end
+ if frame.EnableMouse then
+ frame:EnableMouse(enabled)
+ end
+ if frame.GetChildren then
+ local children = { frame:GetChildren() }
+ for i = 1, #children do
+ setCanvasInteractive(children[i], enabled)
+ end
+ end
+ end
+
+ local function isParentEnabled(spec)
+ if not spec.parent then
+ return true
+ end
+
+ if spec.parentCheck then
+ return spec.parentCheck()
+ end
+
+ if not spec.parent.GetSetting then
+ return true
+ end
+
+ local setting = spec.parent:GetSetting()
+ if not setting then
+ return true
+ end
+
+ return setting:GetValue()
+ end
+
+ local function isControlEnabled(spec)
+ if spec.disabled and spec.disabled() then
+ return false
+ end
+ return isParentEnabled(spec)
+ end
+
+ local function applyCanvasState(canvas, enabled)
+ if canvas.SetAlpha then
+ canvas:SetAlpha(enabled and 1 or 0.5)
+ end
+ setCanvasInteractive(canvas, enabled)
+ end
+
+ reevaluateReactiveControls = function()
+ local panel = SettingsPanel
+ if panel and panel:IsShown() then
+ local settingsList = panel:GetSettingsList()
+ if settingsList and settingsList.ScrollBox then
+ settingsList.ScrollBox:ForEachFrame(function(frame)
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ end)
+ end
+ end
+
+ for _, entry in ipairs(SB._reactiveControls) do
+ local spec = entry[2]
+ if spec.canvas then
+ applyCanvasState(spec.canvas, isControlEnabled(spec))
+ end
+ end
+ end
+
+ local function applyEnabledState(initializer, spec)
+ local enabled = isControlEnabled(spec)
+ if initializer.SetEnabled then
+ initializer:SetEnabled(enabled)
+ end
+ if spec.canvas then
+ applyCanvasState(spec.canvas, enabled)
+ end
+ return enabled
+ end
+
+ local function applyModifiers(initializer, spec)
+ if not initializer then
+ return
+ end
+
+ if spec.disabled or spec.canvas or spec.parent then
+ initializer:AddModifyPredicate(function()
+ return applyEnabledState(initializer, spec)
+ end)
+ applyEnabledState(initializer, spec)
+ end
+
+ if spec.parent then
+ initializer:SetParentInitializer(spec.parent, function()
+ return isParentEnabled(spec)
+ end)
+ end
+
+ if spec.hidden then
+ initializer:AddShownPredicate(function()
+ return not spec.hidden()
+ end)
+ end
+
+ if spec.canvas then
+ SB._reactiveControls[#SB._reactiveControls + 1] = { initializer, spec }
+ end
+ end
+
+ local function colorTableToHex(tbl)
+ if not tbl then
+ return "FFFFFFFF"
+ end
+ return string.format(
+ "%02X%02X%02X%02X",
+ math.floor((tbl.a or 1) * 255 + 0.5),
+ math.floor((tbl.r or 1) * 255 + 0.5),
+ math.floor((tbl.g or 1) * 255 + 0.5),
+ math.floor((tbl.b or 1) * 255 + 0.5)
+ )
+ end
+
+ local function storeCategory(name, category, layout)
+ SB._subcategories[name], SB._subcategoryNames[category], SB._layouts[category] = category, name, layout
+ return category
+ end
+
+ local env = {
+ applyCanvasState = applyCanvasState,
+ applyModifiers = applyModifiers,
+ colorTableToHex = colorTableToHex,
+ defaultSliderFormatter = defaultSliderFormatter,
+ getCanvasLayoutMetrics = getCanvasLayoutMetrics,
+ makeProxySetting = makeProxySetting,
+ makeVarName = makeVarName,
+ makeVarNameFromIdentifier = makeVarNameFromIdentifier,
+ mergeCompositeDefaults = mergeCompositeDefaults,
+ postSet = postSet,
+ propagateModifiers = propagateModifiers,
+ registerCategoryRefreshable = registerCategoryRefreshable,
+ resolveBinding = resolveBinding,
+ resolveCategory = resolveCategory,
+ storeCategory = storeCategory,
+ validateSpecFields = validateSpecFields,
+ }
+
+ assert(type(lib._installPrimitiveLayout) == "function", "LibSettingsBuilder primitive layout module not loaded")
+ assert(type(lib._installStandardControls) == "function", "LibSettingsBuilder controls module not loaded")
+ assert(
+ type(lib._installStandardCollectionControls) == "function",
+ "LibSettingsBuilder collection controls module not loaded"
+ )
+ assert(type(lib._installStandardRowControls) == "function", "LibSettingsBuilder row controls module not loaded")
+ assert(type(lib._installCompositeGroups) == "function", "LibSettingsBuilder composite group module not loaded")
+ assert(
+ type(lib._installCompositeListControls) == "function",
+ "LibSettingsBuilder composite list module not loaded"
+ )
+ assert(type(lib._installUtility) == "function", "LibSettingsBuilder utility module not loaded")
+
+ lib._installPrimitiveLayout(SB, env)
+ lib._installStandardControls(SB, env)
+ lib._installStandardCollectionControls(SB, env)
+ lib._installStandardRowControls(SB, env, config)
+ lib._installCompositeGroups(SB, env)
+ lib._installCompositeListControls(SB, env)
+ lib._installUtility(SB, env)
+
+ return SB
+end
+
+internal.installPageLifecycleHooks = installPageLifecycleHooks
+internal.copyMixin = copyMixin
+internal.setInitializerExtent = setInitializerExtent
+internal.getInitializerData = getInitializerData
+internal.getSettingVariable = getSettingVariable
+internal.registerValueChangedCallback = registerValueChangedCallback
+internal.getOrderedValueEntries = getOrderedValueEntries
+internal.showFrame = showFrame
+internal.setTextureValue = setTextureValue
+internal.setGameTooltipText = setGameTooltipText
+internal.setSimpleTooltip = setSimpleTooltip
+internal.applyActionButtonTextures = applyActionButtonTextures
+internal.evaluateStaticOrFunction = evaluateStaticOrFunction
+internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
+internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
diff --git a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua b/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
deleted file mode 100644
index b780ed0d..00000000
--- a/Libs/LibSettingsBuilder/LibSettingsBuilder.lua
+++ /dev/null
@@ -1,3652 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
--- LibSettingsBuilder: A standalone path-based settings builder for the
--- World of Warcraft Settings API. Provides proxy controls, composite groups
--- and utility helpers.
-
-local MAJOR, MINOR = "LibSettingsBuilder-1.0", 2
-local lib = LibStub:NewLibrary(MAJOR, MINOR)
-if not lib then
- return
-end
-
-lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
-lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
-lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
-lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
-lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
-
-lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
-lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
-
---- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide
---- callbacks registered via RegisterFromTable. Defers automatically if
---- SettingsPanel has not been created yet (Blizzard_Settings loads on demand).
-local function installPageLifecycleHooks()
- if lib._pageLifecycleHooked then
- return
- end
-
- if type(SettingsPanel) ~= "table" or type(SettingsPanel.DisplayCategory) ~= "function" then
- -- SettingsPanel not yet loaded; listen for ADDON_LOADED to retry.
- if lib._pageLifecycleDeferred or type(CreateFrame) ~= "function" then
- return
- end
- lib._pageLifecycleDeferred = true
- local f = CreateFrame("Frame")
- f:RegisterEvent("ADDON_LOADED")
- f:SetScript("OnEvent", function(self)
- if type(SettingsPanel) == "table" and type(SettingsPanel.DisplayCategory) == "function" then
- self:UnregisterAllEvents()
- installPageLifecycleHooks()
- end
- end)
- return
- end
-
- lib._pageLifecycleHooked = true
-
- -- DisplayCategory fires for both sidebar clicks and OpenToCategory.
- -- Retrieve the active category via GetCurrentCategory inside the hook.
- hooksecurefunc(SettingsPanel, "DisplayCategory", function(panel)
- local category = panel.GetCurrentCategory and panel:GetCurrentCategory() or nil
- local old = lib._activeLifecycleCategory
- if old == category then
- return
- end
-
- if old then
- local cbs = lib._pageLifecycleCallbacks[old]
- if cbs and cbs.onHide then
- cbs.onHide()
- end
- end
-
- lib._activeLifecycleCategory = category
- if category then
- local cbs = lib._pageLifecycleCallbacks[category]
- if cbs and cbs.onShow then
- cbs.onShow()
- end
- end
- end)
-
- SettingsPanel:HookScript("OnHide", function()
- local active = lib._activeLifecycleCategory
- if active then
- local cbs = lib._pageLifecycleCallbacks[active]
- if cbs and cbs.onHide then
- cbs.onHide()
- end
- end
- lib._activeLifecycleCategory = nil
- end)
-end
-
-local listElementKeysToHide = {
- "_lsbSubheaderTitle",
- "_lsbInfoTitle",
- "_lsbInfoValue",
- "_lsbCanvas",
- "_lsbInputTitle",
- "_lsbInputEditBox",
- "_lsbInputPreview",
-}
-
-local function copyMixin(target, source)
- for key, value in pairs(source) do
- target[key] = value
- end
- return target
-end
-
-local function setInitializerExtent(initializer, extent)
- if initializer.SetExtent then
- initializer:SetExtent(extent)
- return
- end
-
- initializer.GetExtent = function()
- return extent
- end
-end
-
-local function getInitializerData(initializer)
- if initializer and initializer._lsbData then
- return initializer._lsbData
- end
-
- if initializer and initializer.GetData then
- return initializer:GetData()
- end
-end
-
-local function getSettingVariable(setting)
- return setting and (setting._lsbVariable or setting._variable)
-end
-
-local function registerValueChangedCallback(frame, variable, callback, owner)
- if not variable then
- return
- end
-
- local handles = frame and frame.cbrHandles
- if handles and handles.SetOnValueChangedCallback then
- handles:SetOnValueChangedCallback(variable, callback, owner or frame)
- end
-end
-
-local function makeStableSortKey(value)
- local valueType = type(value)
- if valueType == "number" then
- return "1:" .. string.format("%020.10f", value)
- end
- if valueType == "boolean" then
- return value and "2:true" or "2:false"
- end
- return valueType .. ":" .. tostring(value):lower()
-end
-
-local function getOrderedValueEntries(values)
- local entries = {}
- if not values then
- return entries
- end
-
- for value, label in pairs(values) do
- entries[#entries + 1] = {
- value = value,
- label = label,
- labelSortKey = tostring(label):lower(),
- valueSortKey = makeStableSortKey(value),
- }
- end
-
- table.sort(entries, function(left, right)
- if left.labelSortKey == right.labelSortKey then
- return left.valueSortKey < right.valueSortKey
- end
- return left.labelSortKey < right.labelSortKey
- end)
-
- return entries
-end
-
-local function resetListElement(frame)
- for _, key in ipairs(listElementKeysToHide) do
- local region = frame[key]
- if region then
- region:Hide()
- end
- end
-end
-
-local function hideListElementChildren(frame)
- if not frame or not frame.GetChildren then
- return
- end
-
- local children = { frame:GetChildren() }
- for i = 1, #children do
- local child = children[i]
- if child and child.Hide then
- child:Hide()
- end
- end
-end
-
-local function hideListElementRegions(frame)
- if not frame or not frame.GetRegions then
- return
- end
-
- local regions = { frame:GetRegions() }
- for i = 1, #regions do
- local region = regions[i]
- if region and region.Hide then
- region:Hide()
- end
- end
-end
-
-local function resetPlainListElementFrame(frame)
- hideListElementChildren(frame)
- hideListElementRegions(frame)
- resetListElement(frame)
-end
-
-local function showFrame(frame)
- if frame and frame.Show then
- frame:Show()
- end
-end
-
-local function setGameTooltipText(text, wrap)
- GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true)
-end
-
-local function setSimpleTooltip(owner, text)
- if not owner or not owner.SetScript then
- return
- end
-
- owner:SetScript("OnEnter", nil)
- owner:SetScript("OnLeave", nil)
-
- if not text or text == "" then
- return
- end
-
- owner:SetScript("OnEnter", function(self)
- if not GameTooltip then
- return
- end
-
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- setGameTooltipText(text, true)
- GameTooltip:Show()
- end)
- owner:SetScript("OnLeave", function()
- if GameTooltip_Hide then
- GameTooltip_Hide()
- end
- end)
-end
-
-local function evaluateStaticOrFunction(value, ...)
- if type(value) == "function" then
- return value(...)
- end
- return value
-end
-
-local function createSubheaderTitle(parent, text)
- local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- title:SetPoint("TOPLEFT", parent, "TOPLEFT", 35, -8)
- title:SetJustifyH("LEFT")
- title:SetJustifyV("TOP")
- title:SetFontObject(GameFontHighlight)
- if text ~= nil then
- title:SetText(text)
- end
- title:Show()
- return title
-end
-
-local function createHeaderTitle(parent, text)
- local title = parent:CreateFontString(nil, "OVERLAY", "GameFontHighlightLarge")
- title:SetPoint("TOPLEFT", parent, "TOPLEFT", 7, -16)
- title:SetJustifyH("LEFT")
- title:SetJustifyV("TOP")
- if text ~= nil then
- title:SetText(text)
- end
- title:Show()
- return title
-end
-
-lib.CreateHeaderTitle = createHeaderTitle
-lib.CreateSubheaderTitle = createSubheaderTitle
-
-local function ensureSubheaderTitle(frame)
- if frame._lsbSubheaderTitle then
- return frame._lsbSubheaderTitle
- end
-
- local title = createSubheaderTitle(frame)
- frame._lsbSubheaderTitle = title
- frame.Title = title
- return title
-end
-
-local function ensureInfoRowWidgets(frame)
- if frame._lsbInfoTitle and frame._lsbInfoValue then
- return frame._lsbInfoTitle, frame._lsbInfoValue
- end
-
- local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- title:SetPoint("LEFT", 37, 0)
- title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
- title:SetJustifyH("LEFT")
-
- local value = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
- value:SetPoint("LEFT", frame, "CENTER", -80, 0)
- value:SetJustifyH("LEFT")
-
- frame._lsbInfoTitle = title
- frame._lsbInfoValue = value
- frame.Title = title
- frame.Value = value
-
- return title, value
-end
-
-local function ensureHeaderRowWidgets(frame)
- if frame._lsbHeaderTitle then
- return frame
- end
-
- frame._lsbHeaderTitle = createHeaderTitle(frame)
- frame._lsbHeaderActionButtons = frame._lsbHeaderActionButtons or {}
-
- return frame
-end
-
-local function getSettingsListHeader()
- local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
- return settingsList and settingsList.Header or nil
-end
-
-local function hideHeaderActionButtons(frame)
- for _, button in ipairs(frame._lsbHeaderActionButtons or {}) do
- button:SetScript("OnClick", nil)
- button:SetScript("OnEnter", nil)
- button:SetScript("OnLeave", nil)
- button:Hide()
- end
-end
-
-local function applyHeaderActionButtons(frame, actions, actionParent, rightAnchor)
- ensureHeaderRowWidgets(frame)
- local buttons = frame._lsbHeaderActionButtons
- local anchor = nil
- local visibleCount = 0
-
- actionParent = actionParent or frame
- hideHeaderActionButtons(frame)
-
- for _, action in ipairs(actions or {}) do
- if not evaluateStaticOrFunction(action.hidden, action, frame) then
- visibleCount = visibleCount + 1
-
- local button = buttons[visibleCount]
- if button and button._lsbActionParent ~= actionParent then
- button:Hide()
- button = nil
- end
- if not button then
- button = CreateFrame("Button", nil, actionParent, "UIPanelButtonTemplate")
- button._lsbActionParent = actionParent
- buttons[visibleCount] = button
- end
-
- button:ClearAllPoints()
- if anchor then
- button:SetPoint("RIGHT", anchor, "LEFT", -8, 0)
- elseif rightAnchor then
- button:SetPoint("RIGHT", rightAnchor, "LEFT", -8, 0)
- else
- button:SetPoint("RIGHT", actionParent, "RIGHT", -20, 0)
- end
- button:SetSize(action.width or 100, action.height or 22)
- button:SetText(action.text or action.name or "")
- if button.SetEnabled then
- local enabled = evaluateStaticOrFunction(action.enabled, action, frame)
- if enabled == nil then
- enabled = true
- end
- button:SetEnabled(enabled)
- end
- setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, frame))
- button:SetScript("OnClick", function()
- if action.onClick then
- action.onClick(action, frame)
- end
- end)
- button:Show()
- anchor = button
- end
- end
-end
-
-local function applySubheaderFrame(frame, data)
- local title = ensureSubheaderTitle(frame)
- title:SetText(data.name)
- title:Show()
-end
-
-local function applyHeaderFrame(frame, data)
- ensureHeaderRowWidgets(frame)
- local settingsHeader = data.attachToCategoryHeader and getSettingsListHeader() or nil
- local actionParent = settingsHeader or frame
- local rightAnchor = settingsHeader and settingsHeader.DefaultsButton or nil
-
- if frame._lsbHeaderTitle then
- frame._lsbHeaderTitle:ClearAllPoints()
- frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
- frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
- frame._lsbHeaderTitle:SetText(data.name or "")
- end
-
- applyHeaderActionButtons(frame, data.actions, actionParent, rightAnchor)
-
- if frame._lsbHeaderTitle then
- if data.hideTitle then
- frame._lsbHeaderTitle:Hide()
- return
- end
-
- local titleRight = -20
- local buttons = frame._lsbHeaderActionButtons or {}
- for i = 1, #buttons do
- local button = buttons[i]
- if button and button.IsShown and button:IsShown() then
- frame._lsbHeaderTitle:ClearAllPoints()
- frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
- frame._lsbHeaderTitle:SetPoint("RIGHT", button, "LEFT", -12, 0)
- titleRight = nil
- break
- end
- end
- if titleRight then
- frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", titleRight, 0)
- end
- frame._lsbHeaderTitle:Show()
- end
-end
-
-local function applyInfoRowFrame(frame, data)
- local title, value = ensureInfoRowWidgets(frame)
- local name = evaluateStaticOrFunction(data.name, frame, data)
- local resolvedValue = evaluateStaticOrFunction(data.value, frame, data)
- local isWide = data.wide == true or name == nil or name == ""
- local isMultiline = data.multiline == true
-
- title:ClearAllPoints()
- value:ClearAllPoints()
-
- if isWide then
- title:SetText("")
- title:Hide()
- value:SetPoint("TOPLEFT", frame, "TOPLEFT", 37, isMultiline and -4 or 0)
- value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
- else
- title:SetText(name or "")
- title:SetPoint("LEFT", 37, 0)
- title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
- title:Show()
- value:SetPoint("LEFT", frame, "CENTER", -80, 0)
- value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
- end
-
- if value.SetWordWrap then
- value:SetWordWrap(isMultiline)
- end
- if value.SetJustifyV then
- value:SetJustifyV(isMultiline and "TOP" or "MIDDLE")
- end
- if value.SetJustifyH then
- value:SetJustifyH("LEFT")
- end
- value:SetText(resolvedValue or "")
- if not isWide then
- title:Show()
- end
- value:Show()
-end
-
-local function ensureInputRowWidgets(frame)
- if frame._lsbInputTitle and frame._lsbInputEditBox and frame._lsbInputPreview then
- return frame._lsbInputTitle, frame._lsbInputEditBox, frame._lsbInputPreview
- end
-
- local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- title:SetJustifyH("LEFT")
- title:SetWordWrap(false)
-
- local editBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
- editBox:SetAutoFocus(false)
-
- local preview = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- preview:SetJustifyH("LEFT")
- preview:SetJustifyV("TOP")
- preview:SetWordWrap(false)
- preview:Hide()
-
- frame._lsbInputTitle = title
- frame._lsbInputEditBox = editBox
- frame._lsbInputPreview = preview
- frame.Title = title
- frame.EditBox = editBox
- frame.Preview = preview
-
- return title, editBox, preview
-end
-
-local function setInputPreviewText(frame, text)
- local preview = frame._lsbInputPreview
- if not preview then
- return
- end
-
- text = text and tostring(text) or ""
- preview:SetText(text)
- if text ~= "" then
- preview:Show()
- else
- preview:Hide()
- end
-end
-
-local function cancelInputPreviewTimer(frame)
- local timer = frame and frame._lsbInputPreviewTimer
- if timer and timer.Cancel then
- timer:Cancel()
- end
- if frame then
- frame._lsbInputPreviewTimer = nil
- end
-end
-
-local function syncInputRowText(frame, value)
- local editBox = frame and frame._lsbInputEditBox
- if not editBox then
- return
- end
-
- value = value == nil and "" or tostring(value)
- if editBox.GetText and editBox:GetText() == value then
- return
- end
-
- frame._lsbUpdatingInputText = true
- editBox:SetText(value)
- frame._lsbUpdatingInputText = nil
-end
-
-local function resolveInputPreview(frame)
- local data = frame and frame._lsbInputData
- local setting = frame and frame._lsbInputSetting
- if not data or not data.resolveText then
- setInputPreviewText(frame, nil)
- return
- end
-
- local value = setting and setting.GetValue and setting:GetValue() or nil
- setInputPreviewText(frame, data.resolveText(value, setting, frame))
-end
-
-local function scheduleInputPreview(frame, immediate)
- cancelInputPreviewTimer(frame)
-
- local data = frame and frame._lsbInputData
- if not data or not data.resolveText then
- setInputPreviewText(frame, nil)
- return
- end
-
- local delay = immediate and 0 or (data.debounce or 0)
- if delay > 0 and C_Timer and C_Timer.NewTimer then
- frame._lsbInputPreviewTimer = C_Timer.NewTimer(delay, function()
- frame._lsbInputPreviewTimer = nil
- resolveInputPreview(frame)
- end)
- return
- end
-
- resolveInputPreview(frame)
-end
-
-local function applyInputRowEnabledState(frame, enabled)
- if not frame then
- return
- end
-
- if frame.SetAlpha then
- frame:SetAlpha(enabled and 1 or 0.5)
- end
-
- local editBox = frame._lsbInputEditBox
- if not editBox then
- return
- end
-
- if editBox.SetEnabled then
- editBox:SetEnabled(enabled)
- end
- if editBox.EnableMouse then
- editBox:EnableMouse(enabled)
- end
-end
-
-local function applyInputRowFrame(frame, data)
- local title, editBox, preview = ensureInputRowWidgets(frame)
- local hasPreview = data.resolveText ~= nil
-
- title:ClearAllPoints()
- title:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, hasPreview and "TOPLEFT" or "LEFT", 37, hasPreview and -6 or 0)
- title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
- title:SetJustifyV(hasPreview and "TOP" or "MIDDLE")
- title:SetText(data.name)
- title:Show()
-
- editBox:ClearAllPoints()
- editBox:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, "CENTER", -80, hasPreview and -2 or 0)
- editBox:SetSize(data.width or 140, 20)
- if editBox.SetNumeric then
- editBox:SetNumeric(data.numeric == true)
- end
- if editBox.SetMaxLetters and data.maxLetters then
- editBox:SetMaxLetters(data.maxLetters)
- end
- if editBox.SetTextInsets then
- editBox:SetTextInsets(6, 6, 0, 0)
- end
- editBox:Show()
-
- preview:ClearAllPoints()
- preview:SetPoint("TOPLEFT", editBox, "BOTTOMLEFT", 0, -3)
- preview:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
- if hasPreview then
- preview:Show()
- else
- preview:Hide()
- end
-
- frame._lsbInputData = data
- frame._lsbInputSetting = data.setting
- editBox._lsbOwnerFrame = frame
-
- if not editBox._lsbInputScriptsBound then
- editBox:SetScript("OnTextChanged", function(self)
- local owner = self._lsbOwnerFrame
- if not owner or owner._lsbUpdatingInputText then
- return
- end
-
- local setting = owner._lsbInputSetting
- local text = self:GetText() or ""
- if setting and setting.SetValue then
- setting:SetValue(text)
- end
-
- local inputData = owner._lsbInputData
- if inputData and inputData.onTextChanged then
- inputData.onTextChanged(text, setting, owner)
- end
-
- scheduleInputPreview(owner, false)
- end)
- editBox:SetScript("OnEnterPressed", function(self)
- if self.ClearFocus then
- self:ClearFocus()
- end
- end)
- editBox:SetScript("OnEscapePressed", function(self)
- local owner = self._lsbOwnerFrame
- if owner then
- local setting = owner._lsbInputSetting
- local value = setting and setting.GetValue and setting:GetValue() or ""
- syncInputRowText(owner, value)
- scheduleInputPreview(owner, true)
- end
- if self.ClearFocus then
- self:ClearFocus()
- end
- end)
- editBox._lsbInputScriptsBound = true
- end
-
- syncInputRowText(frame, data.setting and data.setting.GetValue and data.setting:GetValue() or "")
-
- local ownVariable = data.settingVariable
- registerValueChangedCallback(frame, ownVariable, function()
- local currentSetting = frame._lsbInputSetting
- local value = currentSetting and currentSetting.GetValue and currentSetting:GetValue() or ""
- syncInputRowText(frame, value)
- end, frame)
-
- if data.watchVariables then
- for _, variable in ipairs(data.watchVariables) do
- if variable ~= ownVariable then
- registerValueChangedCallback(frame, variable, function()
- scheduleInputPreview(frame, true)
- end, frame)
- end
- end
- end
-
- scheduleInputPreview(frame, true)
-end
-
-local function applyEmbedCanvasFrame(frame, data, initializer)
- local canvas = data.canvas
- if not canvas then
- return
- end
-
- frame._lsbCanvas = canvas
- canvas:SetParent(frame)
- canvas:ClearAllPoints()
- canvas:SetPoint("TOPLEFT", 0, 0)
- canvas:SetPoint("TOPRIGHT", 0, 0)
- canvas:SetHeight(initializer:GetExtent())
- canvas:Show()
-end
-
-local function ensureListElementCallbackHandles(frame)
- if frame.cbrHandles or not (Settings and Settings.CreateCallbackHandleContainer) then
- return
- end
-
- frame.cbrHandles = Settings.CreateCallbackHandleContainer()
-end
-
-local function initializerShouldShow(initializer)
- if initializer and initializer.ShouldShow then
- return initializer:ShouldShow()
- end
-
- if initializer and initializer._shownPredicates then
- for _, predicate in ipairs(initializer._shownPredicates) do
- if not predicate() then
- return false
- end
- end
- end
-
- return true
-end
-
-local function initializerIsEnabled(initializer)
- if initializer and initializer.EvaluateModifyPredicates then
- return initializer:EvaluateModifyPredicates()
- end
-
- if initializer and initializer._modifyPredicates then
- for _, predicate in ipairs(initializer._modifyPredicates) do
- if not predicate() then
- return false
- end
- end
- end
-
- return true
-end
-
-local function createCustomListRowInitializer(template, data, extent, initFrame)
- local initializer = Settings.CreateElementInitializer(template, data)
- setInitializerExtent(initializer, extent)
-
- initializer.InitFrame = function(self, frame)
- ensureListElementCallbackHandles(frame)
-
- frame.data = self.data
- if frame.Text then
- frame.Text:SetText("")
- end
- if frame.NewFeature then
- frame.NewFeature:Hide()
- end
-
- resetPlainListElementFrame(frame)
- initFrame(frame, self.data, self)
- self._lsbActiveFrame = frame
-
- if not frame._lsbHasCustomEvaluateState then
- frame.EvaluateState = function(control)
- local currentInitializer = control.GetElementData and control:GetElementData()
- or control._lsbInitializer
- if currentInitializer and currentInitializer.SetEnabled then
- currentInitializer:SetEnabled(initializerIsEnabled(currentInitializer))
- end
- control:SetShown(initializerShouldShow(currentInitializer))
- end
- frame._lsbHasCustomEvaluateState = true
- end
-
- frame._lsbInitializer = self
- frame:EvaluateState()
- end
-
- initializer.Resetter = function(self, frame)
- if frame.cbrHandles and frame.cbrHandles.Unregister then
- frame.cbrHandles:Unregister()
- end
- if frame.Text then
- frame.Text:SetText("")
- end
- if frame.NewFeature then
- frame.NewFeature:Hide()
- end
- if frame._lsbCanvas then
- frame._lsbCanvas:Hide()
- frame._lsbCanvas = nil
- end
- if self._lsbActiveFrame == frame then
- self._lsbActiveFrame = nil
- end
- if self._lsbResetFrame then
- self._lsbResetFrame(frame, self)
- end
-
- resetPlainListElementFrame(frame)
- frame.data = nil
- frame._lsbInitializer = nil
- end
-
- return initializer
-end
-
-local ScrollDropdownMethods = {}
-
-function ScrollDropdownMethods:GetSetting()
- if self.initializer and self.initializer.GetSetting then
- return self.initializer:GetSetting()
- end
- return self.lsbData and self.lsbData.setting or nil
-end
-
-function ScrollDropdownMethods:RefreshDropdownText(value)
- local dropdown = self.Control and self.Control.Dropdown
- if not dropdown then
- return
- end
-
- local setting = self:GetSetting()
- local currentValue = value
- if currentValue == nil and setting and setting.GetValue then
- currentValue = setting:GetValue()
- end
-
- local values = self.lsbData and self.lsbData.values
- if type(values) == "function" then
- values = values()
- end
- local text = values and values[currentValue] or tostring(currentValue or "")
-
- if dropdown.OverrideText then
- dropdown:OverrideText(text)
- elseif dropdown.SetText then
- dropdown:SetText(text)
- end
-end
-
-function ScrollDropdownMethods:SetValue(value)
- self:RefreshDropdownText(value)
-end
-
-function ScrollDropdownMethods:InitDropdown()
- local setting = self:GetSetting()
- local data = self.lsbData or {}
- local scrollHeight = data.scrollHeight or 200
-
- local dropdown = self.Control and self.Control.Dropdown
- if not dropdown or not setting then
- return
- end
-
- dropdown:SetupMenu(function(_, rootDescription)
- rootDescription:SetScrollMode(scrollHeight)
-
- local values = data.values
- if type(values) == "function" then
- values = values()
- end
- if not values then
- return
- end
-
- for _, entry in ipairs(getOrderedValueEntries(values)) do
- rootDescription:CreateRadio(entry.label, function()
- return setting:GetValue() == entry.value
- end, function()
- setting:SetValue(entry.value)
- self:RefreshDropdownText(entry.value)
- end, entry.value)
- end
- end)
-
- self:RefreshDropdownText()
-end
-
-local function configureScrollDropdownFrame(frame, initializer)
- if not frame._lsbOriginalSetValue then
- frame._lsbOriginalSetValue = frame.SetValue
- end
-
- copyMixin(frame, ScrollDropdownMethods)
- frame.initializer = initializer
- frame.lsbData = getInitializerData(initializer) or {}
- initializer._lsbActiveFrame = frame
- frame:InitDropdown()
-end
-
-if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownControlMixin then
- hooksecurefunc(SettingsDropdownControlMixin, "Init", function(frame, initializer)
- local data = getInitializerData(initializer)
- if not data or data._lsbKind ~= "scrollDropdown" then
- if frame._lsbOriginalSetValue then
- frame.SetValue = frame._lsbOriginalSetValue
- end
- frame.initializer = initializer
- frame.lsbData = nil
- return
- end
-
- configureScrollDropdownFrame(frame, initializer)
- end)
-
- lib._scrollDropdownHookInstalled = true
-end
-
-local function roundSliderValue(value, step, minValue, maxValue)
- local actualStep = step or 1
- local baseValue = minValue or 0
- local rounded = math.floor(((value - baseValue) / actualStep) + 0.5) * actualStep + baseValue
- if minValue then
- rounded = math.max(minValue, rounded)
- end
- if maxValue then
- rounded = math.min(maxValue, rounded)
- end
- return rounded
-end
-
-local function getSliderStepCount(minValue, maxValue, step)
- return math.max(1, math.floor(((maxValue - minValue) / (step or 1)) + 0.5))
-end
-
-local function createInlineSliderFormatters()
- if not MinimalSliderWithSteppersMixin or not MinimalSliderWithSteppersMixin.Label then
- return nil
- end
-
- return {
- [MinimalSliderWithSteppersMixin.Label.Right] = function()
- return ""
- end,
- }
-end
-
-local function attachInlineSliderEditor(slider, textLabel, editBoxWidth)
- if slider._lsbValueButton then
- return
- end
-
- local function hideEditBox()
- if slider._lsbEditBox and slider._lsbEditBox.ClearFocus then
- slider._lsbEditBox:ClearFocus()
- end
- if slider._lsbEditBox then
- slider._lsbEditBox:Hide()
- end
- if textLabel and textLabel.Show then
- textLabel:Show()
- end
- end
-
- local function applyEditBoxValue()
- local editBox = slider._lsbEditBox
- local enteredValue = editBox and tonumber(editBox:GetText())
- if enteredValue then
- local minValue = slider._lsbMinValue or 0
- local maxValue = slider._lsbMaxValue
- if slider._lsbRangeResolver then
- local nextMin, nextMax, nextStep = slider._lsbRangeResolver(enteredValue)
- if nextMin ~= nil then
- minValue = nextMin
- end
- if nextMax ~= nil then
- maxValue = nextMax
- end
- if nextStep ~= nil then
- slider._lsbStep = nextStep
- end
- if maxValue ~= nil then
- slider._lsbMaxValue = maxValue
- end
- slider._lsbMinValue = minValue
- end
-
- slider:SetValue(roundSliderValue(enteredValue, slider._lsbStep, minValue, maxValue))
- end
- hideEditBox()
- end
-
- local valueButton = CreateFrame("Button", nil, slider)
- valueButton:RegisterForClicks("LeftButtonDown")
- valueButton:SetAllPoints(textLabel)
- slider._lsbValueButton = valueButton
-
- local editBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate")
- editBox:SetAutoFocus(false)
- editBox:SetNumeric(false)
- editBox:SetSize(editBoxWidth or 50, 20)
- editBox:SetPoint("CENTER", textLabel, "CENTER")
- editBox:SetJustifyH("CENTER")
- editBox:Hide()
- slider._lsbEditBox = editBox
-
- editBox:SetScript("OnEnterPressed", applyEditBoxValue)
- editBox:SetScript("OnEscapePressed", hideEditBox)
- editBox:SetScript("OnEditFocusLost", hideEditBox)
-
- valueButton:SetScript("OnClick", function()
- editBox:SetText(textLabel and textLabel.GetText and textLabel:GetText() or "")
- if textLabel and textLabel.Hide then
- textLabel:Hide()
- end
- editBox:Show()
- editBox:SetFocus()
- editBox:HighlightText()
- end)
-end
-
-local function configureInlineSlider(slider, textLabel, field, onValueChanged)
- local minValue = field.min or 0
- local maxValue = field.max or 1
- local step = field.step or 1
-
- slider._lsbMinValue = minValue
- slider._lsbMaxValue = maxValue
- slider._lsbStep = step
- slider._lsbRangeResolver = field.getRange
-
- if slider.MinText then
- slider.MinText:Hide()
- end
- if slider.MaxText then
- slider.MaxText:Hide()
- end
- if slider.RightText then
- slider.RightText:Hide()
- end
-
- if slider.Init then
- slider:Init(minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters())
- if slider.Slider and slider.Slider.SetValueStep then
- slider.Slider:SetValueStep(step)
- end
- else
- slider:SetMinMaxValues(minValue, maxValue)
- slider:SetValueStep(step)
- slider:SetObeyStepOnDrag(true)
- end
-
- attachInlineSliderEditor(slider, textLabel, field.editWidth or 50)
-
- if not slider._lsbValueChangedBound then
- local function handleValueChanged(_, value)
- local rounded = roundSliderValue(value, slider._lsbStep, slider._lsbMinValue, slider._lsbMaxValue)
- if textLabel and textLabel.SetText then
- textLabel:SetText(tostring(rounded))
- end
- if onValueChanged then
- onValueChanged(rounded)
- end
- end
-
- if slider.RegisterCallback and MinimalSliderWithSteppersMixin and MinimalSliderWithSteppersMixin.Event then
- slider:RegisterCallback(MinimalSliderWithSteppersMixin.Event.OnValueChanged, handleValueChanged, slider)
- else
- slider:HookScript("OnValueChanged", handleValueChanged)
- end
- slider._lsbValueChangedBound = true
- end
-end
-
-local function applyCollectionRowStyle(row, item)
- local alpha = item and item.alpha or 1
-
- if row._label and row._label.SetFontObject and item and item.labelFontObject then
- row._label:SetFontObject(item.labelFontObject)
- end
- if row._label and row._label.SetTextColor and item and item.labelColor then
- row._label:SetTextColor(
- item.labelColor[1] or 1,
- item.labelColor[2] or 1,
- item.labelColor[3] or 1,
- item.labelColor[4] or 1
- )
- end
- if row._label and row._label.SetAlpha then
- row._label:SetAlpha(alpha)
- end
- if row._icon and row._icon.SetAlpha then
- row._icon:SetAlpha(alpha)
- end
- if row._icon and row._icon.SetDesaturated then
- row._icon:SetDesaturated(item and item.iconDesaturated == true or false)
- end
- if row._icon and row._icon.SetVertexColor then
- local color = item and item.iconVertexColor
- if color then
- row._icon:SetVertexColor(color[1] or 1, color[2] or 1, color[3] or 1, color[4] or 1)
- else
- row._icon:SetVertexColor(1, 1, 1, 1)
- end
- end
-end
-
-local function bindCollectionRowTooltip(row, item)
- if not row or not row.SetScript then
- return
- end
-
- row:SetScript("OnEnter", nil)
- row:SetScript("OnLeave", nil)
-
- if not item then
- return
- end
-
- row:SetScript("OnEnter", function(self)
- if self._highlight and self._highlight.Show then
- self._highlight:Show()
- end
- if item.onEnter then
- item.onEnter(self, item)
- elseif item.tooltip then
- if GameTooltip then
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- setGameTooltipText(item.tooltip, true)
- GameTooltip:Show()
- end
- end
- end)
- row:SetScript("OnLeave", function(self)
- if self._highlight and self._highlight.Hide then
- self._highlight:Hide()
- end
- if item.onLeave then
- item.onLeave(self, item)
- elseif GameTooltip_Hide then
- GameTooltip_Hide()
- end
- end)
-end
-
-local function ensureHighlight(row)
- if row._highlight then
- return row._highlight
- end
-
- local highlight = row:CreateTexture(nil, "BACKGROUND")
- highlight:SetAllPoints()
- highlight:SetColorTexture(1, 1, 1, 0.08)
- highlight:Hide()
- row._highlight = highlight
- return highlight
-end
-
-local function ensureSwatchCollectionRow(row)
- if row._lsbSwatchRow then
- return
- end
-
- row._lsbSwatchRow = true
- row:SetHeight(26)
- ensureHighlight(row)
-
- row._icon = row:CreateTexture(nil, "ARTWORK")
- row._icon:SetPoint("LEFT", 0, 0)
- row._icon:SetSize(16, 16)
- row._icon:Hide()
-
- row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
- row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
- row._label:SetJustifyH("LEFT")
- row._label:SetWordWrap(false)
-
- row._swatch = lib.CreateColorSwatch(row)
- row._swatch:SetPoint("RIGHT", row, "RIGHT", -18, 0)
-end
-
-local function refreshSwatchCollectionRow(row, item)
- ensureSwatchCollectionRow(row)
-
- if item.icon then
- row._icon:SetTexture(item.icon)
- row._icon:Show()
- row._label:ClearAllPoints()
- row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
- row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
- else
- row._icon:SetTexture(nil)
- row._icon:Hide()
- row._label:ClearAllPoints()
- row._label:SetPoint("LEFT", row, "LEFT", 0, 0)
- row._label:SetPoint("RIGHT", row, "RIGHT", -100, 0)
- end
-
- row._label:SetText(item.label or "")
- applyCollectionRowStyle(row, item)
- bindCollectionRowTooltip(row, item)
-
- local color = item.color or {}
- local colorValue = color.value or color
- row._swatch:SetColorRGB(colorValue.r or 1, colorValue.g or 1, colorValue.b or 1)
- setSimpleTooltip(row._swatch, item.swatchTooltip or color.tooltip)
- row._swatch:SetScript("OnClick", function()
- local onClick = color.onClick or item.onColorClick
- if onClick then
- onClick(item, row)
- end
- end)
- if row._swatch.SetEnabled then
- row._swatch:SetEnabled(
- evaluateStaticOrFunction(item.enabled, item, row) ~= false
- and evaluateStaticOrFunction(color.enabled, item, row) ~= false
- )
- end
-end
-
-local function ensureEditorCollectionRow(row)
- if row._lsbEditorRow then
- return
- end
-
- row._lsbEditorRow = true
- row:SetHeight(34)
- ensureHighlight(row)
-
- row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- row._label:SetPoint("LEFT", row, "LEFT", 10, 0)
- row._label:SetWidth(70)
- row._label:SetJustifyH("LEFT")
-
- row._fieldWidgets = {}
- row._swatch = lib.CreateColorSwatch(row)
- row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._removeButton:SetSize(70, 22)
-end
-
-local function ensureEditorFieldWidgets(row, index)
- local widgets = row._fieldWidgets[index]
- if widgets then
- return widgets
- end
-
- local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
- local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- valueText:SetJustifyH("LEFT")
-
- widgets = {
- slider = slider,
- valueText = valueText,
- }
- row._fieldWidgets[index] = widgets
- return widgets
-end
-
-local function refreshEditorCollectionRow(row, item)
- ensureEditorCollectionRow(row)
-
- row._label:SetText(item.label or "")
- applyCollectionRowStyle(row, item)
- bindCollectionRowTooltip(row, item)
-
- local labelAnchor = row._label
- local previousControl = labelAnchor
- local previousValueText = nil
- local fields = item.fields or {}
-
- for i = 1, #fields do
- local field = fields[i]
- local widgets = ensureEditorFieldWidgets(row, i)
- local slider = widgets.slider
- local valueText = widgets.valueText
- local minValue, maxValue, step = field.min or 0, field.max or 1, field.step or 1
-
- if field.getRange then
- local nextMin, nextMax, nextStep = field.getRange(item, field.value)
- if nextMin ~= nil then
- minValue = nextMin
- end
- if nextMax ~= nil then
- maxValue = nextMax
- end
- if nextStep ~= nil then
- step = nextStep
- end
- end
-
- field.min = minValue
- field.max = maxValue
- field.step = step
-
- slider:ClearAllPoints()
- if previousValueText then
- slider:SetPoint("LEFT", previousValueText, "RIGHT", field.gap or 12, 0)
- else
- slider:SetPoint("LEFT", row._label, "RIGHT", 8, 0)
- end
- slider:SetWidth(field.sliderWidth or 120)
-
- valueText:ClearAllPoints()
- valueText:SetPoint("LEFT", slider, "RIGHT", 6, 0)
- valueText:SetWidth(field.valueWidth or 40)
-
- configureInlineSlider(slider, valueText, field, function(rounded)
- if row._lsbRefreshing then
- return
- end
- if field.onValueChanged then
- field.onValueChanged(rounded, item, row)
- end
- end)
-
- previousControl = slider
- previousValueText = valueText
- end
-
- local color = item.color or {}
- row._swatch:ClearAllPoints()
- if previousValueText then
- row._swatch:SetPoint("LEFT", previousValueText, "RIGHT", 10, 0)
- else
- row._swatch:SetPoint("LEFT", row._label, "RIGHT", 10, 0)
- end
-
- row._removeButton:ClearAllPoints()
- row._removeButton:SetPoint("LEFT", row._swatch, "RIGHT", 8, 0)
- row._removeButton:SetSize((item.remove and item.remove.width) or 70, 22)
- row._removeButton:SetText((item.remove and item.remove.text) or REMOVE or "Remove")
- row._removeButton:SetScript("OnClick", function()
- if item.remove and item.remove.onClick then
- item.remove.onClick(item, row)
- end
- end)
- if row._removeButton.SetEnabled then
- row._removeButton:SetEnabled(item.remove == nil or item.remove.enabled ~= false)
- end
- setSimpleTooltip(row._removeButton, item.remove and item.remove.tooltip)
-
- row._lsbRefreshing = true
- for i = 1, #fields do
- local field = fields[i]
- local widgets = row._fieldWidgets[i]
- widgets.slider._lsbMinValue = field.min or 0
- widgets.slider._lsbMaxValue = field.max or 1
- widgets.slider._lsbStep = field.step or 1
- if widgets.slider.SetValue then
- widgets.slider:SetValue(field.value or 0)
- end
- widgets.valueText:SetText(tostring(field.value or 0))
- end
- row._lsbRefreshing = nil
-
- row._swatch:SetColorRGB((color.value and color.value.r) or 1, (color.value and color.value.g) or 1, (color.value and color.value.b) or 1)
- row._swatch:SetScript("OnClick", function()
- if color.onClick then
- color.onClick(item, row)
- end
- end)
- setSimpleTooltip(row._swatch, color.tooltip)
- if row._swatch.SetEnabled then
- row._swatch:SetEnabled(color.enabled ~= false)
- end
-end
-
-local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
-
-local function ensureActionsCollectionRow(row)
- if row._lsbActionsRow then
- return
- end
-
- row._lsbActionsRow = true
- row:SetHeight(26)
- ensureHighlight(row)
-
- row._icon = row:CreateTexture(nil, "ARTWORK")
- row._icon:SetPoint("LEFT", 0, 0)
- row._icon:SetSize(20, 20)
-
- row._label = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
- row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
- row._label:SetJustifyH("LEFT")
- row._label:SetWordWrap(false)
-
- row._buttons = {}
- for _, key in ipairs(ACTION_BUTTON_ORDER) do
- local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._buttons[key] = button
- end
-end
-
-local function refreshActionsCollectionRow(row, item)
- ensureActionsCollectionRow(row)
-
- row._label:SetText(item.label or "")
- row._icon:SetTexture(item.icon or 134400)
- applyCollectionRowStyle(row, item)
- bindCollectionRowTooltip(row, item)
-
- local anchor = nil
- for _, key in ipairs(ACTION_BUTTON_ORDER) do
- local button = row._buttons[key]
- local action = item.actions and item.actions[key] or nil
-
- button:ClearAllPoints()
- button:SetScript("OnClick", nil)
- button:SetScript("OnEnter", nil)
- button:SetScript("OnLeave", nil)
-
- if action and not evaluateStaticOrFunction(action.hidden, action, row, item) then
- if not anchor then
- button:SetPoint("RIGHT", row, "RIGHT", 0, 0)
- else
- button:SetPoint("RIGHT", anchor, "LEFT", -2, 0)
- end
- button:SetSize(action.width or 26, action.height or 22)
- button:SetText(action.text or "")
- if button.SetEnabled then
- local enabled = evaluateStaticOrFunction(action.enabled, action, row, item)
- if enabled == nil then
- enabled = true
- end
- button:SetEnabled(enabled)
- end
- setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, row, item))
- button:SetScript("OnClick", function()
- if action.onClick then
- action.onClick(item, row, action)
- end
- end)
- button:Show()
- anchor = button
- else
- button:Hide()
- end
- end
-
- if anchor then
- row._label:ClearAllPoints()
- row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
- row._label:SetPoint("RIGHT", anchor, "LEFT", -6, 0)
- else
- row._label:ClearAllPoints()
- row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
- row._label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
- end
-end
-
-local function ensureModeInputRow(row)
- if row._lsbModeInputRow then
- return
- end
-
- row._lsbModeInputRow = true
- row:SetHeight(28)
-
- row._modeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._modeButton:SetPoint("LEFT", row, "LEFT", 0, 0)
- row._modeButton:SetSize(58, 22)
-
- row._editBox = CreateFrame("EditBox", nil, row, "InputBoxTemplate")
- row._editBox:SetPoint("LEFT", row._modeButton, "RIGHT", 6, 0)
- row._editBox:SetSize(120, 20)
- row._editBox:SetAutoFocus(false)
- if row._editBox.SetNumeric then
- row._editBox:SetNumeric(true)
- end
- if row._editBox.SetMaxLetters then
- row._editBox:SetMaxLetters(10)
- end
- if row._editBox.SetTextInsets then
- row._editBox:SetTextInsets(6, 6, 0, 0)
- end
-
- row._placeholder = row._editBox:CreateFontString(nil, "OVERLAY", "GameFontDisable")
- row._placeholder:SetPoint("LEFT", row._editBox, "LEFT", 6, 0)
- row._placeholder:SetPoint("RIGHT", row._editBox, "RIGHT", -6, 0)
- row._placeholder:SetJustifyH("LEFT")
- row._placeholder:SetWordWrap(false)
-
- row._previewIcon = row:CreateTexture(nil, "ARTWORK")
- row._previewIcon:SetPoint("LEFT", row._editBox, "RIGHT", 8, 0)
- row._previewIcon:SetSize(16, 16)
- row._previewIcon:Hide()
-
- row._previewLabel = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- row._previewLabel:SetPoint("LEFT", row._previewIcon, "RIGHT", 4, 0)
- row._previewLabel:SetJustifyH("LEFT")
- row._previewLabel:SetWordWrap(false)
- row._previewLabel:Hide()
-
- row._submitButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- row._submitButton:SetPoint("RIGHT", row, "RIGHT", 0, 0)
- row._submitButton:SetSize(44, 22)
- row._submitButton:SetText(ADD or "Add")
-
- row._previewLabel:SetPoint("RIGHT", row._submitButton, "LEFT", -6, 0)
-
- row._editBox:SetScript("OnEditFocusGained", function()
- row._lsbHasFocus = true
- if row._lsbTrailerRefresh then
- row._lsbTrailerRefresh(row)
- end
- end)
- row._editBox:SetScript("OnEditFocusLost", function()
- row._lsbHasFocus = false
- if row._lsbTrailerRefresh then
- row._lsbTrailerRefresh(row)
- end
- end)
- row._editBox:SetScript("OnTextChanged", function(self)
- if row._lsbSyncingText then
- return
- end
- local trailer = row._lsbTrailerData
- if trailer and trailer.onTextChanged then
- trailer.onTextChanged(self:GetText() or "", trailer, row, row._lsbSectionData)
- end
- if row._lsbTrailerRefresh then
- row._lsbTrailerRefresh(row)
- end
- end)
- row._editBox:SetScript("OnEnterPressed", function()
- local trailer = row._lsbTrailerData
- if trailer and trailer.onSubmit then
- local keepFocus = trailer.onSubmit(trailer, row, row._lsbSectionData)
- if keepFocus then
- row._editBox:SetFocus()
- row._editBox:HighlightText()
- end
- end
- end)
- row._editBox:SetScript("OnTabPressed", function()
- local trailer = row._lsbTrailerData
- local keepFocus = nil
- if trailer and trailer.onTabPressed then
- keepFocus = trailer.onTabPressed(trailer, row, row._lsbSectionData)
- end
- if row._lsbTrailerRefresh then
- row._lsbTrailerRefresh(row)
- end
- if keepFocus then
- row._editBox:SetFocus()
- row._editBox:HighlightText()
- end
- end)
- row._editBox:SetScript("OnEscapePressed", function(self)
- local trailer = row._lsbTrailerData
- if trailer and trailer.onEscapePressed then
- trailer.onEscapePressed(trailer, row, row._lsbSectionData)
- end
- if self.ClearFocus then
- self:ClearFocus()
- end
- row._lsbHasFocus = false
- if row._lsbTrailerRefresh then
- row._lsbTrailerRefresh(row)
- end
- end)
-end
-
-local function getModeInputTrailerValue(trailer, key, row, sectionData)
- return evaluateStaticOrFunction(trailer and trailer[key], trailer, row, sectionData)
-end
-
-local function refreshModeInputRow(row, trailer, sectionData)
- ensureModeInputRow(row)
-
- row._lsbTrailerData = trailer
- row._lsbSectionData = sectionData
-
- row._lsbTrailerRefresh = function(activeRow)
- local currentTrailer = activeRow._lsbTrailerData or {}
- local activeSectionData = activeRow._lsbSectionData
- local disabled = getModeInputTrailerValue(currentTrailer, "disabled", activeRow, activeSectionData) == true
- local modeEnabled = getModeInputTrailerValue(currentTrailer, "modeEnabled", activeRow, activeSectionData)
- local inputEnabled = getModeInputTrailerValue(currentTrailer, "inputEnabled", activeRow, activeSectionData)
- local submitEnabled = getModeInputTrailerValue(currentTrailer, "submitEnabled", activeRow, activeSectionData)
- local modeText = getModeInputTrailerValue(currentTrailer, "modeText", activeRow, activeSectionData)
- local modeTooltip = getModeInputTrailerValue(currentTrailer, "modeTooltip", activeRow, activeSectionData)
- local text = getModeInputTrailerValue(currentTrailer, "inputText", activeRow, activeSectionData) or ""
- local placeholder = getModeInputTrailerValue(currentTrailer, "placeholder", activeRow, activeSectionData)
- local previewIcon = getModeInputTrailerValue(currentTrailer, "previewIcon", activeRow, activeSectionData)
- local previewText = getModeInputTrailerValue(currentTrailer, "previewText", activeRow, activeSectionData)
- local submitText = getModeInputTrailerValue(currentTrailer, "submitText", activeRow, activeSectionData)
- local submitTooltip = getModeInputTrailerValue(currentTrailer, "submitTooltip", activeRow, activeSectionData)
-
- activeRow._modeButton:SetText(modeText or "")
- setSimpleTooltip(activeRow._modeButton, modeTooltip)
- activeRow._modeButton:SetScript("OnClick", function()
- if currentTrailer.onToggleMode then
- currentTrailer.onToggleMode(currentTrailer, activeRow, activeRow._lsbSectionData)
- end
- if activeRow._lsbTrailerRefresh then
- activeRow._lsbTrailerRefresh(activeRow)
- end
- end)
- if activeRow._modeButton.SetEnabled then
- activeRow._modeButton:SetEnabled(not disabled and modeEnabled ~= false)
- end
-
- if activeRow._editBox.GetText and activeRow._editBox:GetText() ~= text then
- activeRow._lsbSyncingText = true
- activeRow._editBox:SetText(text)
- activeRow._lsbSyncingText = nil
- end
- if activeRow._editBox.SetEnabled then
- activeRow._editBox:SetEnabled(not disabled and inputEnabled ~= false)
- end
-
- activeRow._placeholder:SetText(placeholder or "")
- if activeRow._lsbHasFocus or text ~= "" then
- activeRow._placeholder:Hide()
- else
- activeRow._placeholder:Show()
- end
-
- if previewIcon then
- activeRow._previewIcon:SetTexture(previewIcon)
- activeRow._previewIcon:Show()
- else
- activeRow._previewIcon:SetTexture(nil)
- activeRow._previewIcon:Hide()
- end
-
- if previewText and previewText ~= "" then
- activeRow._previewLabel:SetText(previewText)
- activeRow._previewLabel:Show()
- else
- activeRow._previewLabel:SetText("")
- activeRow._previewLabel:Hide()
- end
-
- activeRow._submitButton:SetText(submitText or ADD or "Add")
- setSimpleTooltip(activeRow._submitButton, submitTooltip)
- activeRow._submitButton:SetScript("OnClick", function()
- if currentTrailer.onSubmit then
- local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData)
- if keepFocus then
- activeRow._editBox:SetFocus()
- activeRow._editBox:HighlightText()
- end
- end
- end)
- if activeRow._submitButton.SetEnabled then
- activeRow._submitButton:SetEnabled(not disabled and submitEnabled ~= false)
- end
- end
-
- row._lsbTrailerRefresh(row)
-end
-
-local function ensureCollectionContent(frame)
- if frame._lsbCollectionContent then
- showFrame(frame._lsbCollectionContent)
- return frame._lsbCollectionContent
- end
-
- local content = CreateFrame("Frame", nil, frame)
- content:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)
- content:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)
- content:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)
- content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)
- frame._lsbCollectionContent = content
- return content
-end
-
-local function ensureFlatCollectionWidgets(frame, data)
- if frame._lsbCollectionScrollBox then
- showFrame(frame._lsbCollectionScrollBox)
- showFrame(frame._lsbCollectionScrollBar)
- return
- end
-
- local insetLeft = data.insetLeft or 37
- local insetRight = data.insetRight or 20
- local insetTop = data.insetTop or 0
- local insetBottom = data.insetBottom or 10
-
- local scrollBox = CreateFrame("Frame", nil, frame, "WowScrollBoxList")
- scrollBox:SetPoint("TOPLEFT", frame, "TOPLEFT", insetLeft, insetTop)
- scrollBox:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30, insetBottom)
-
- local scrollBar = CreateFrame("EventFrame", nil, frame, "MinimalScrollBar")
- scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
- scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
-
- local view = CreateScrollBoxListLinearView()
- view:SetElementExtent(data.rowHeight or 26)
- view:SetElementInitializer("Frame", function(rowFrame, rowData)
- local preset = rowData.preset or data.preset
- if preset == "swatch" then
- refreshSwatchCollectionRow(rowFrame, rowData.item)
- elseif preset == "editor" then
- refreshEditorCollectionRow(rowFrame, rowData.item)
- end
- end)
-
- local dataProvider = CreateDataProvider()
- ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)
- scrollBox:SetDataProvider(dataProvider)
-
- frame._lsbCollectionScrollBox = scrollBox
- frame._lsbCollectionScrollBar = scrollBar
- frame._lsbCollectionView = view
- frame._lsbCollectionDataProvider = dataProvider
-end
-
-local function refreshFlatCollection(frame, data)
- ensureFlatCollectionWidgets(frame, data)
-
- local scrollBox = frame._lsbCollectionScrollBox
- local dataProvider = frame._lsbCollectionDataProvider
- local items = data.items and data.items(frame) or {}
-
- if dataProvider and dataProvider.Flush then
- dataProvider:Flush()
- end
-
- for _, item in ipairs(items or {}) do
- if dataProvider and dataProvider.Insert then
- dataProvider:Insert({
- preset = data.preset,
- item = item,
- })
- end
- end
-
- if scrollBox and scrollBox.SetDataProvider then
- scrollBox:SetDataProvider(dataProvider)
- end
-end
-
-local function ensureSectionHeaderRow(content, headers, sectionKey, title)
- local row = headers[sectionKey]
- if row then
- return row
- end
-
- row = CreateFrame("Frame", nil, content)
- row:SetHeight(28)
- row._title = createSubheaderTitle(row, title)
- headers[sectionKey] = row
- return row
-end
-
-local function ensureSectionEmptyLabel(content, labels, sectionKey)
- local label = labels[sectionKey]
- if label then
- return label
- end
-
- label = content:CreateFontString(nil, "OVERLAY", "GameFontDisable")
- label:SetJustifyH("LEFT")
- labels[sectionKey] = label
- return label
-end
-
-local function refreshSectionedCollection(frame, data)
- local content = ensureCollectionContent(frame)
- local sections = data.sections and data.sections(frame) or {}
- local headers = frame._lsbSectionHeaders or {}
- local rowPools = frame._lsbSectionRowPools or {}
- local emptyLabels = frame._lsbSectionEmptyLabels or {}
- local trailerRows = frame._lsbSectionTrailerRows or {}
- local y = 0
- local insetLeft = data.insetLeft or 37
- local insetRight = data.insetRight or 20
- local rowSpacing = data.rowSpacing or 4
- local sectionSpacing = data.sectionSpacing or 12
-
- frame._lsbSectionHeaders = headers
- frame._lsbSectionRowPools = rowPools
- frame._lsbSectionEmptyLabels = emptyLabels
- frame._lsbSectionTrailerRows = trailerRows
-
- for sectionKey, pool in pairs(rowPools) do
- for _, row in ipairs(pool) do
- row:Hide()
- end
- end
- for _, row in pairs(headers) do
- row:Hide()
- end
- for _, label in pairs(emptyLabels) do
- label:Hide()
- end
- for _, trailer in pairs(trailerRows) do
- trailer:Hide()
- end
-
- for _, section in ipairs(sections) do
- local sectionKey = section.key or section.name or tostring(_)
- local header = ensureSectionHeaderRow(content, headers, sectionKey, section.title or section.name or "")
- header._title:SetText(section.title or section.name or "")
- header:ClearAllPoints()
- header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
- header:SetPoint("RIGHT", content, "RIGHT", 0, 0)
- header:Show()
- y = y - (section.headerHeight or 28)
-
- local items = section.items or {}
- local pool = rowPools[sectionKey] or {}
- rowPools[sectionKey] = pool
-
- if #items == 0 and section.emptyText then
- local label = ensureSectionEmptyLabel(content, emptyLabels, sectionKey)
- label:SetText(section.emptyText)
- label:ClearAllPoints()
- label:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
- label:Show()
- y = y - ((section.emptyHeight or 26) + rowSpacing)
- end
-
- for index, item in ipairs(items) do
- local row = pool[index]
- if not row then
- row = CreateFrame("Frame", nil, content)
- pool[index] = row
- end
-
- refreshActionsCollectionRow(row, item)
- row:ClearAllPoints()
- row:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
- row:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
- row:Show()
- y = y - ((section.rowHeight or 26) + rowSpacing)
- end
-
- if section.trailer and section.trailer.preset == "modeInput" then
- local trailerRow = trailerRows[sectionKey]
- if not trailerRow then
- trailerRow = CreateFrame("Frame", nil, content)
- trailerRows[sectionKey] = trailerRow
- end
-
- refreshModeInputRow(trailerRow, section.trailer, section)
- trailerRow:ClearAllPoints()
- trailerRow:SetPoint("TOPLEFT", content, "TOPLEFT", insetLeft, y)
- trailerRow:SetPoint("RIGHT", content, "RIGHT", -insetRight, 0)
- trailerRow:Show()
- y = y - (section.trailerHeight or 28)
- end
-
- y = y - (section.spacingAfter or sectionSpacing)
- end
-end
-
-local function applyCollectionFrame(frame, data, initializer)
- frame.OnDefault = data.onDefault
- frame._lsbCollectionData = data
- frame._lsbCollectionInitializer = initializer
-
- if data.sections then
- refreshSectionedCollection(frame, data)
- else
- refreshFlatCollection(frame, data)
- end
-end
-
---------------------------------------------------------------------------------
--- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
--- Replicates Blizzard's Settings panel positioning so canvas pages are
--- visually indistinguishable from vertical-layout pages.
---
--- Measurements from Blizzard_SettingControls.xml/.lua:
--- Element height: 26 (all control types)
--- Section header: 45 (GameFontHighlightLarge at TOPLEFT 7, -16)
--- Label left offset: indent + 37
--- Label right bound: CENTER - 85
--- Control anchor: CENTER - 80 (checkbox, slider, color swatch)
--- Button anchor: CENTER - 40 (width 200)
--- Indent per level: 15
---------------------------------------------------------------------------------
-
-lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults
- or {
- elementHeight = 26,
- headerHeight = 50,
- labelX = 37,
- controlCenterX = -80,
- buttonCenterX = -40,
- buttonWidth = 200,
- sliderWidth = 250,
- swatchCenterX = -73,
- verifiedPatch = "Retail 12.0/12.1",
- }
-
-local CanvasLayout = {}
-lib.CanvasLayout = CanvasLayout
-
-local function getCanvasLayoutMetrics(layout)
- return layout._metrics or lib.CanvasLayoutDefaults
-end
-
-function CanvasLayout:_Advance(h)
- self.yPos = self.yPos - h
-end
-
-function CanvasLayout:_CreateRow(h)
- local metrics = getCanvasLayoutMetrics(self)
- h = h or metrics.elementHeight
- local row = CreateFrame("Frame", nil, self.frame)
- row:SetPoint("TOPLEFT", 0, self.yPos)
- row:SetPoint("RIGHT")
- row:SetHeight(h)
- self.elements[#self.elements + 1] = row
- self:_Advance(h)
- return row
-end
-
-function CanvasLayout:_AddLabel(row, text, fontObject)
- local metrics = getCanvasLayoutMetrics(self)
- local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal")
- label:SetPoint("LEFT", metrics.labelX, 0)
- label:SetPoint("RIGHT", row, "CENTER", -85, 0)
- label:SetJustifyH("LEFT")
- label:SetWordWrap(false)
- label:SetText(text)
- row._label = label
- return label
-end
-
---- Add a page header using Blizzard's SettingsListTemplate.Header.
---- Provides Title, Options_HorizontalDivider, and DefaultsButton.
----@return Frame row (row._title, row._defaultsButton exposed)
-function CanvasLayout:AddHeader(text)
- local metrics = getCanvasLayoutMetrics(self)
- local row = self:_CreateRow(metrics.headerHeight)
- local settingsList = CreateFrame("Frame", nil, row, "SettingsListTemplate")
- settingsList:SetAllPoints(row)
- settingsList.ScrollBox:Hide()
- settingsList.ScrollBar:Hide()
- settingsList.Header.Title:SetText(text)
- row._title = settingsList.Header.Title
- row._defaultsButton = settingsList.Header.DefaultsButton
- return row
-end
-
---- Add vertical spacing.
-function CanvasLayout:AddSpacer(height)
- self:_Advance(height)
-end
-
---- Add a description / informational text row.
-function CanvasLayout:AddDescription(text, fontObject)
- local metrics = getCanvasLayoutMetrics(self)
- local row = self:_CreateRow()
- local label = row:CreateFontString(nil, "OVERLAY", fontObject or "GameFontNormal")
- label:SetPoint("LEFT", metrics.labelX, 0)
- label:SetPoint("RIGHT", row, "RIGHT", -10, 0)
- label:SetJustifyH("LEFT")
- label:SetText(text)
- row._text = label
- return row
-end
-
---- Add a color swatch row (label + clickable swatch).
----@return Frame row, Button swatch
-function CanvasLayout:AddColorSwatch(labelText)
- local metrics = getCanvasLayoutMetrics(self)
- local row = self:_CreateRow()
- self:_AddLabel(row, labelText)
- local swatch = lib.CreateColorSwatch(row)
- swatch:SetPoint("LEFT", row, "CENTER", metrics.swatchCenterX, 0)
- row._swatch = swatch
- return row, swatch
-end
-
---- Add a slider row (label + MinimalSliderWithSteppers).
----@return Frame row, Slider slider, FontString valueText
-function CanvasLayout:AddSlider(labelText, min, max, step)
- local metrics = getCanvasLayoutMetrics(self)
- local row = self:_CreateRow()
- self:_AddLabel(row, labelText)
- local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
- slider:SetWidth(metrics.sliderWidth)
- slider:SetPoint("LEFT", row, "CENTER", metrics.controlCenterX, 3)
- slider:SetMinMaxValues(min, max)
- slider:SetValueStep(step or 1)
- slider:SetObeyStepOnDrag(true)
- local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
- valueText:SetPoint("LEFT", slider, "RIGHT", 8, 0)
- valueText:SetWidth(40)
- valueText:SetJustifyH("LEFT")
- row._slider = slider
- row._valueText = valueText
- return row, slider, valueText
-end
-
---- Add a button row (label + UIPanelButton).
----@return Frame row, Button button
-function CanvasLayout:AddButton(labelText, buttonText)
- local metrics = getCanvasLayoutMetrics(self)
- local row = self:_CreateRow()
- self:_AddLabel(row, labelText)
- local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
- button:SetSize(metrics.buttonWidth, 26)
- button:SetPoint("LEFT", row, "CENTER", metrics.buttonCenterX, 0)
- button:SetText(buttonText)
- row._button = button
- return row, button
-end
-
---- Add a scroll list that fills the remaining vertical space.
----@return Frame scrollBox, EventFrame scrollBar, table view
-function CanvasLayout:AddScrollList(elementExtent)
- local metrics = getCanvasLayoutMetrics(self)
- local scrollBox = CreateFrame("Frame", nil, self.frame, "WowScrollBoxList")
- scrollBox:SetPoint("TOPLEFT", metrics.labelX, self.yPos)
- scrollBox:SetPoint("BOTTOMRIGHT", -30, 10)
- local scrollBar = CreateFrame("EventFrame", nil, self.frame, "MinimalScrollBar")
- scrollBar:SetPoint("TOPLEFT", scrollBox, "TOPRIGHT", 5, 0)
- scrollBar:SetPoint("BOTTOMLEFT", scrollBox, "BOTTOMRIGHT", 5, 0)
- local view = CreateScrollBoxListLinearView()
- view:SetElementExtent(elementExtent)
- ScrollUtil.InitScrollBoxListWithScrollBar(scrollBox, scrollBar, view)
- return scrollBox, scrollBar, view
-end
-
---------------------------------------------------------------------------------
--- Static utilities (shared across all instances)
---------------------------------------------------------------------------------
-
---- Create a color swatch button using Blizzard's SettingsColorSwatchTemplate.
---- Inherits ColorSwatchTemplate (SwatchBg/InnerBorder/Color layers) and
---- SettingsColorSwatchMixin (hover effects, color picker integration).
----@param parent Frame
----@return Button swatch (swatch._tex points to swatch.Color for backward compat)
-function lib.CreateColorSwatch(parent)
- local swatch = CreateFrame("Button", nil, parent, "SettingsColorSwatchTemplate")
- swatch._tex = swatch.Color
- return swatch
-end
-
---------------------------------------------------------------------------------
--- Slider editable-value hook (global, runs once per lib version)
---------------------------------------------------------------------------------
-
-if not lib._sliderHookInstalled then
- local function setupSliderEditableValue()
- if not SettingsSliderControlMixin then
- return
- end
-
- local function findValueLabel(sliderWithSteppers)
- if sliderWithSteppers._label then
- return sliderWithSteppers._label
- end
- if sliderWithSteppers.RightText then
- return sliderWithSteppers.RightText
- end
- if sliderWithSteppers.Label then
- return sliderWithSteppers.Label
- end
- for i = 1, select("#", sliderWithSteppers:GetRegions()) do
- local region = select(i, sliderWithSteppers:GetRegions())
- if region and region:IsObjectType("FontString") then
- return region
- end
- end
- return nil
- end
-
- local function getSliderValueText(self)
- local setting = self and self._lsbCurrentSetting
- if not setting or not setting.GetValue then
- return ""
- end
- return tostring(setting:GetValue())
- end
-
- local function hideSliderEditBox(self)
- local editBox = self and self._lsbEditBox
- local valueLabel = self and self._lsbValueLabel
- if not editBox or not valueLabel then
- return
- end
- editBox:ClearFocus()
- editBox:Hide()
- valueLabel:Show()
- end
-
- local function applySliderEditValue(self)
- local editBox = self and self._lsbEditBox
- local setting = self and self._lsbCurrentSetting
- local sliderWithSteppers = self and self.SliderWithSteppers
- if not editBox or not setting or not sliderWithSteppers or not sliderWithSteppers.Slider then
- hideSliderEditBox(self)
- return
- end
-
- local num = tonumber(editBox:GetText())
- if num then
- local slider = sliderWithSteppers.Slider
- local min, max = slider:GetMinMaxValues()
- num = math.max(min, math.min(max, num))
- local step = slider:GetValueStep()
- if step and step > 0 then
- num = math.floor(num / step + 0.5) * step
- end
- setting:SetValue(num)
- end
-
- hideSliderEditBox(self)
- end
-
- local function anchorSliderValueButton(self)
- local valueLabel = self and self._lsbValueLabel
- local valueButton = self and self._lsbValueButton
- if not valueLabel or not valueButton then
- return
- end
-
- if valueButton.ClearAllPoints then
- valueButton:ClearAllPoints()
- end
- valueButton:SetAllPoints(valueLabel)
- end
-
- hooksecurefunc(SettingsSliderControlMixin, "Init", function(self, initializer)
- local sliderWithSteppers = self.SliderWithSteppers
- if not sliderWithSteppers then
- return
- end
-
- local valueLabel = findValueLabel(sliderWithSteppers)
- if not valueLabel then
- return
- end
-
- self._lsbCurrentSetting = initializer:GetSetting()
- self._lsbValueLabel = valueLabel
-
- if not self._lsbValueButton then
- local btn = CreateFrame("Button", nil, sliderWithSteppers)
- btn:RegisterForClicks("LeftButtonDown")
- self._lsbValueButton = btn
-
- local editBox = CreateFrame("EditBox", nil, sliderWithSteppers, "InputBoxTemplate")
- editBox:SetAutoFocus(false)
- editBox:SetNumeric(false)
- editBox:SetSize(50, 20)
- editBox:SetPoint("CENTER", valueLabel, "CENTER")
- editBox:SetJustifyH("CENTER")
- editBox:Hide()
- self._lsbEditBox = editBox
-
- editBox:SetScript("OnEnterPressed", function()
- applySliderEditValue(self)
- end)
- editBox:SetScript("OnEscapePressed", function()
- hideSliderEditBox(self)
- end)
- editBox:SetScript("OnEditFocusLost", function()
- hideSliderEditBox(self)
- end)
-
- btn:SetScript("OnClick", function()
- local setting = self._lsbCurrentSetting
- local currentValueLabel = self._lsbValueLabel
- if not setting or not currentValueLabel then
- return
- end
-
- anchorSliderValueButton(self)
- editBox:SetText(getSliderValueText(self))
- currentValueLabel:Hide()
- editBox:Show()
- editBox:SetFocus()
- editBox:HighlightText()
- end)
- end
-
- anchorSliderValueButton(self)
-
- if self._lsbEditBox and self._lsbEditBox.ClearFocus then
- self._lsbEditBox:ClearFocus()
- self._lsbEditBox:Hide()
- end
- valueLabel:Show()
- end)
- end
-
- setupSliderEditableValue()
- lib._sliderHookInstalled = true
-end
-
---------------------------------------------------------------------------------
--- Path accessors: built-in dot-path resolution with numeric key support
---------------------------------------------------------------------------------
-
-local function defaultGetNestedValue(tbl, path)
- local current = tbl
- for segment in path:gmatch("[^.]+") do
- if type(current) ~= "table" then
- return nil
- end
- local val = current[segment]
- if val == nil then
- local num = tonumber(segment)
- if num then
- val = current[num]
- end
- end
- current = val
- end
- return current
-end
-
-local function defaultSetNestedValue(tbl, path, value)
- local current, lastKey = tbl, nil
- for segment in path:gmatch("[^.]+") do
- if lastKey then
- local resolved = lastKey
- if current[lastKey] == nil then
- local num = tonumber(lastKey)
- if num and current[num] ~= nil then
- resolved = num
- end
- end
- if current[resolved] == nil then
- current[resolved] = {}
- end
- current = current[resolved]
- end
- lastKey = segment
- end
- local resolved = lastKey
- if current[lastKey] == nil then
- local num = tonumber(lastKey)
- if num then
- resolved = num
- end
- end
- current[resolved] = value
-end
-
---- Creates a path adapter for resolving dot-delimited paths to get/set/default
---- bindings. Built-in accessors handle numeric path segments (e.g. "colors.0").
----@param config table
---- Required: getStore (function() -> table), getDefaults (function() -> table)
---- Optional: getNestedValue, setNestedValue (custom path accessors)
----@return table adapter with :resolve(path) and :read(path) methods
-function lib.PathAdapter(config)
- assert(config.getStore, "PathAdapter: getStore is required")
- assert(config.getDefaults, "PathAdapter: getDefaults is required")
-
- local getNested = config.getNestedValue or defaultGetNestedValue
- local setNested = config.setNestedValue or defaultSetNestedValue
-
- return {
- resolve = function(self, path)
- return {
- get = function()
- return getNested(config.getStore(), path)
- end,
- set = function(value)
- setNested(config.getStore(), path, value)
- end,
- default = getNested(config.getDefaults(), path),
- }
- end,
- read = function(self, path)
- return getNested(config.getStore(), path)
- end,
- }
-end
-
---------------------------------------------------------------------------------
--- Factory
---------------------------------------------------------------------------------
-
---- Create a new SettingsBuilder instance.
----@param config table
---- Required fields:
---- varPrefix string e.g. "ECM"
---- onChanged function(spec, value) called after each setter
---- Optional fields:
---- pathAdapter table PathAdapter instance for path-based controls
---- compositeDefaults table keyed by composite function name
----@return table builder instance with the full SB API
-function lib:New(config)
- assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required")
- assert(config.onChanged, "LibSettingsBuilder: onChanged is required")
-
- local SB = {}
- SB._rootCategory = nil
- SB._rootCategoryName = nil
- SB._currentSubcategory = nil
- SB._subcategories = {}
- SB._subcategoryNames = {}
- SB._layouts = {}
- SB._firstHeaderAdded = {}
- SB._reactiveControls = {}
- SB._categoryRefreshables = {}
-
- SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
- SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
- SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
- SB.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
- SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
- SB.CreateHeaderTitle = lib.CreateHeaderTitle
- SB.CreateSubheaderTitle = lib.CreateSubheaderTitle
-
- ----------------------------------------------------------------------------
- -- Internal helpers
- ----------------------------------------------------------------------------
-
- local function defaultSliderFormatter(value)
- return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value)
- end
-
- local adapter = config.pathAdapter
-
- local function makeVarNameFromIdentifier(identifier)
- return config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
- end
-
- local function makeVarName(spec)
- local id = spec.key or spec.path
- return makeVarNameFromIdentifier(id)
- end
-
- local function resolveCategory(spec)
- return spec.category or SB._currentSubcategory or SB._rootCategory
- end
-
- local function registerCategoryRefreshable(category, initializer)
- if not category or not initializer then
- return
- end
-
- local refreshables = SB._categoryRefreshables[category]
- if not refreshables then
- refreshables = {}
- SB._categoryRefreshables[category] = refreshables
- end
-
- for _, existing in ipairs(refreshables) do
- if existing == initializer then
- return
- end
- end
-
- refreshables[#refreshables + 1] = initializer
- end
-
- local reevaluateReactiveControls
- local setCanvasInteractive
-
- local function postSet(spec, value, setting)
- if spec.onSet then
- spec.onSet(value, setting)
- end
- config.onChanged(spec, value)
- reevaluateReactiveControls()
- end
-
- --- Resolves a spec into a binding with get/set/default.
- --- Handler mode: spec provides explicit get, set, key, and default.
- --- Path mode: spec provides a path string; the pathAdapter generates get/set/default.
- local function resolveBinding(spec)
- local hasPath = spec.path ~= nil
- local hasHandler = spec.get ~= nil or spec.set ~= nil
-
- assert(not (hasPath and hasHandler), "spec cannot have both path and get/set")
-
- if hasHandler then
- assert(spec.get, "handler mode requires get")
- assert(spec.set, "handler mode requires set")
- assert(spec.key, "handler mode requires key")
- return { get = spec.get, set = spec.set, default = spec.default }
- end
-
- assert(hasPath, "spec must have either path or get/set")
- assert(adapter, "path mode requires a pathAdapter on the builder")
-
- local binding = adapter:resolve(spec.path)
- if spec.default ~= nil then
- binding.default = spec.default
- end
- return binding
- end
-
- --- Consolidates the getter/setter/default/transform/register boilerplate
- --- shared by Checkbox, Slider, Dropdown, and Custom.
- local function makeProxySetting(spec, varType, defaultFallback, binding)
- local variable = makeVarName(spec)
- local cat = resolveCategory(spec)
- local setting
-
- binding = binding or resolveBinding(spec)
-
- local function getter()
- local val = binding.get()
- if spec.getTransform then
- val = spec.getTransform(val)
- end
- return val
- end
-
- local function applyValue(value)
- if spec.setTransform then
- value = spec.setTransform(value)
- end
- binding.set(value)
- return value
- end
-
- local function setter(value)
- value = applyValue(value)
- postSet(spec, value, setting)
- end
-
- local function setValueNoCallback(_, value)
- value = applyValue(value)
- config.onChanged(spec, value)
- reevaluateReactiveControls()
- end
-
- local default = binding.default
- if spec.getTransform then
- default = spec.getTransform(default)
- end
-
- if default == nil then
- default = defaultFallback
- end
-
- setting = Settings.RegisterProxySetting(
- cat,
- variable,
- varType,
- spec.name,
- default,
- getter,
- setter
- )
- setting.SetValueNoCallback = setValueNoCallback
- setting._lsbVariable = variable
-
- return setting, cat
- end
-
- --- Copies inherited modifier keys from a composite spec onto a child spec
- --- when the child hasn't set them explicitly.
- local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" }
- local function propagateModifiers(target, source)
- for _, key in ipairs(MODIFIER_KEYS) do
- if target[key] == nil then
- target[key] = source[key]
- end
- end
- end
-
- --- Merges compositeDefaults for the given composite function name onto spec.
- --- Spec values win over defaults.
- local function mergeCompositeDefaults(functionName, spec)
- local defaults = config.compositeDefaults and config.compositeDefaults[functionName]
- if not defaults then
- return spec or {}
- end
- local merged = {}
- for k, v in pairs(defaults) do
- merged[k] = v
- end
- if spec then
- for k, v in pairs(spec) do
- merged[k] = v
- end
- end
- return merged
- end
-
- ----------------------------------------------------------------------------
- -- Debug spec validation (active only when LSB_DEBUG is truthy)
- ----------------------------------------------------------------------------
-
- local COMMON_SPEC_FIELDS = {
- path = true,
- name = true,
- tooltip = true,
- category = true,
- onSet = true,
- getTransform = true,
- setTransform = true,
- parent = true,
- parentCheck = true,
- disabled = true,
- hidden = true,
- layout = true,
- type = true,
- desc = true,
- get = true,
- set = true,
- key = true,
- default = true,
- }
-
- local EXTRA_FIELDS_BY_TYPE = {
- checkbox = {},
- slider = { min = true, max = true, step = true, formatter = true },
- dropdown = { values = true, scrollHeight = true },
- color = {},
- input = {
- debounce = true,
- maxLetters = true,
- numeric = true,
- onTextChanged = true,
- resolveText = true,
- watch = true,
- watchVariables = true,
- width = true,
- },
- custom = { template = true, varType = true },
- }
-
- local function validateSpecFields(controlType, spec)
- if not LSB_DEBUG then
- return
- end
- local allowed = EXTRA_FIELDS_BY_TYPE[controlType]
- if not allowed then
- return
- end
- for key in pairs(spec) do
- if not COMMON_SPEC_FIELDS[key] and not allowed[key] then
- print(
- "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '"
- .. tostring(key)
- .. "' on "
- .. controlType
- .. " control '"
- .. tostring(spec.name or spec.path)
- .. "'"
- )
- end
- end
- end
-
- setCanvasInteractive = function(frame, enabled)
- if frame.SetEnabled then
- frame:SetEnabled(enabled)
- end
- if frame.EnableMouse then
- frame:EnableMouse(enabled)
- end
- if frame.GetChildren then
- local children = { frame:GetChildren() }
- for i = 1, #children do
- setCanvasInteractive(children[i], enabled)
- end
- end
- end
-
- local function isParentEnabled(spec)
- if not spec.parent then
- return true
- end
-
- if spec.parentCheck then
- return spec.parentCheck()
- end
-
- if not spec.parent.GetSetting then
- return true
- end
- local setting = spec.parent:GetSetting()
- if not setting then
- return true
- end
- return setting:GetValue()
- end
-
- local function isControlEnabled(spec)
- if spec.disabled and spec.disabled() then
- return false
- end
- return isParentEnabled(spec)
- end
-
- local function applyCanvasState(canvas, enabled)
- if canvas.SetAlpha then
- canvas:SetAlpha(enabled and 1 or 0.5)
- end
- setCanvasInteractive(canvas, enabled)
- end
-
- reevaluateReactiveControls = function()
- -- Force the WoW settings panel to re-evaluate visible control states.
- local panel = SettingsPanel
- if panel and panel:IsShown() then
- local settingsList = panel:GetSettingsList()
- if settingsList and settingsList.ScrollBox then
- settingsList.ScrollBox:ForEachFrame(function(frame)
- if frame.EvaluateState then
- frame:EvaluateState()
- end
- end)
- end
- end
-
- -- Canvas controls aren't part of the settings list, handle directly
- for _, entry in ipairs(SB._reactiveControls) do
- local s = entry[2]
- if s.canvas then
- applyCanvasState(s.canvas, isControlEnabled(s))
- end
- end
- end
-
- local function applyEnabledState(initializer, spec)
- local enabled = isControlEnabled(spec)
- if initializer.SetEnabled then
- initializer:SetEnabled(enabled)
- end
- if spec.canvas then
- applyCanvasState(spec.canvas, enabled)
- end
- return enabled
- end
-
- local function applyModifiers(initializer, spec)
- if not initializer then
- return
- end
-
- if spec.disabled or spec.canvas or spec.parent then
- initializer:AddModifyPredicate(function()
- return applyEnabledState(initializer, spec)
- end)
- applyEnabledState(initializer, spec)
- end
-
- if spec.parent then
- local predicate = function()
- return isParentEnabled(spec)
- end
- initializer:SetParentInitializer(spec.parent, predicate)
- end
-
- if spec.hidden then
- initializer:AddShownPredicate(function()
- return not spec.hidden()
- end)
- end
-
- if spec.canvas then
- SB._reactiveControls[#SB._reactiveControls + 1] = { initializer, spec }
- end
- end
-
- local function colorTableToHex(tbl)
- if not tbl then
- return "FFFFFFFF"
- end
- return string.format(
- "%02X%02X%02X%02X",
- math.floor((tbl.a or 1) * 255 + 0.5),
- math.floor((tbl.r or 1) * 255 + 0.5),
- math.floor((tbl.g or 1) * 255 + 0.5),
- math.floor((tbl.b or 1) * 255 + 0.5)
- )
- end
-
- ----------------------------------------------------------------------------
- -- Category management
- ----------------------------------------------------------------------------
-
- function SB.CreateRootCategory(name)
- local category, layout = Settings.RegisterVerticalLayoutCategory(name)
- SB._rootCategory = category
- SB._rootCategoryName = name
- SB._layouts[category] = layout
- SB._currentSubcategory = nil
- SB._firstHeaderAdded = {}
- return category
- end
-
- function SB.CreateSubcategory(name, parentCategory)
- local parent = parentCategory or SB._rootCategory
- local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
- SB._subcategories[name] = subcategory
- SB._subcategoryNames[subcategory] = name
- SB._layouts[subcategory] = layout
- SB._currentSubcategory = subcategory
- return subcategory
- end
-
- function SB.CreateCanvasSubcategory(frame, name, parentCategory)
- local parent = parentCategory or SB._rootCategory
- local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
- SB._subcategories[name] = subcategory
- SB._subcategoryNames[subcategory] = name
- SB._layouts[subcategory] = layout
- return subcategory
- end
-
- --- Creates a canvas subcategory with a CanvasLayout engine attached.
- --- Returns a layout object with AddHeader, AddDescription, AddSlider,
- --- AddColorSwatch, AddButton, AddScrollList methods that position
- --- controls to match Blizzard's vertical-layout settings pages.
- ---@param name string Subcategory display name.
- ---@param parentCategory? table Parent category (defaults to root).
- ---@return table layout CanvasLayout instance (layout.frame for the raw frame).
- function SB.CreateCanvasLayout(name, parentCategory)
- local frame = CreateFrame("Frame", nil)
- SB.CreateCanvasSubcategory(frame, name, parentCategory)
- local metrics = copyMixin({}, lib.CanvasLayoutDefaults)
- local layout = setmetatable({
- frame = frame,
- yPos = 0,
- elements = {},
- _metrics = metrics,
- }, { __index = lib.CanvasLayout })
- return layout
- end
-
- function SB.SetCanvasLayoutDefaults(overrides)
- if not overrides then
- return lib.CanvasLayoutDefaults
- end
-
- for key, value in pairs(overrides) do
- lib.CanvasLayoutDefaults[key] = value
- end
-
- return lib.CanvasLayoutDefaults
- end
-
- function SB.ConfigureCanvasLayout(layout, overrides)
- assert(layout, "ConfigureCanvasLayout: layout is required")
- if not overrides then
- return getCanvasLayoutMetrics(layout)
- end
-
- layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides)
- return layout._metrics
- end
-
- --- Static color swatch factory, forwarded from lib for convenience.
- SB.CreateColorSwatch = lib.CreateColorSwatch
-
- function SB.RegisterCategories()
- if SB._rootCategory then
- Settings.RegisterAddOnCategory(SB._rootCategory)
- end
- end
-
- function SB.GetRootCategoryID()
- return SB._rootCategory and SB._rootCategory:GetID()
- end
-
- function SB.GetSubcategoryID(name)
- local category = SB._subcategories[name]
- return category and category:GetID()
- end
-
- function SB.GetRootCategory()
- return SB._rootCategory
- end
-
- function SB.GetSubcategory(name)
- return SB._subcategories[name]
- end
-
- function SB.HasCategory(category)
- return category ~= nil and SB._layouts[category] ~= nil
- end
-
- ----------------------------------------------------------------------------
- -- Proxy controls
- ----------------------------------------------------------------------------
-
- function SB.Checkbox(spec)
- validateSpecFields("checkbox", spec)
- local setting, cat = makeProxySetting(spec, Settings.VarType.Boolean, false)
- local initializer = Settings.CreateCheckbox(cat, setting, spec.tooltip)
- applyModifiers(initializer, spec)
- return initializer, setting
- end
-
- function SB.Slider(spec)
- validateSpecFields("slider", spec)
- local setting, cat = makeProxySetting(spec, Settings.VarType.Number, 0)
-
- local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1)
- options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or defaultSliderFormatter)
-
- local initializer = Settings.CreateSlider(cat, setting, options, spec.tooltip)
- applyModifiers(initializer, spec)
-
- return initializer, setting
- end
-
- function SB.Dropdown(spec)
- validateSpecFields("dropdown", spec)
- local binding = resolveBinding(spec)
- local cat = resolveCategory(spec)
-
- local default = binding.default
- if spec.getTransform then
- default = spec.getTransform(default)
- end
-
- local varType = spec.varType
- or (type(default) == "number" and Settings.VarType.Number)
- or Settings.VarType.String
-
- local setting = makeProxySetting(spec, varType, "", binding)
- local function optionsGenerator()
- local container = Settings.CreateControlTextContainer()
- local values = type(spec.values) == "function" and spec.values() or spec.values
- if values then
- for _, entry in ipairs(getOrderedValueEntries(values)) do
- container:Add(entry.value, entry.label)
- end
- end
- return container:GetData()
- end
- setting._optionsGen = optionsGenerator
-
- local initializer
- if spec.scrollHeight then
- initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
- initializer._lsbData = {
- _lsbKind = "scrollDropdown",
- setting = setting,
- values = spec.values,
- scrollHeight = spec.scrollHeight,
- name = spec.name,
- tooltip = spec.tooltip,
- }
- if initializer.SetSetting then
- initializer:SetSetting(setting)
- end
- initializer._lsbRefreshFrame = function(frame)
- if frame and frame.RefreshDropdownText then
- frame:RefreshDropdownText()
- end
- end
- registerCategoryRefreshable(cat, initializer)
- else
- initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
- end
-
- if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
- initializer:SetSetting(setting)
- end
- if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame = function(frame)
- if frame and frame.InitDropdown then
- frame:InitDropdown(initializer)
- elseif frame and frame.SetValue and setting.GetValue then
- frame:SetValue(setting:GetValue())
- end
- end
- registerCategoryRefreshable(cat, initializer)
- end
-
- if not initializer.GetSetting then
- initializer.GetSetting = function()
- return setting
- end
- end
-
- applyModifiers(initializer, spec)
-
- return initializer, setting
- end
-
- function SB.Color(spec)
- validateSpecFields("color", spec)
- local variable = makeVarName(spec)
- local cat = resolveCategory(spec)
- local binding = resolveBinding(spec)
-
- local function getter()
- local tbl = binding.get()
- return colorTableToHex(tbl)
- end
-
- local settingRef
-
- local function setter(hexValue)
- local color = CreateColorFromHexString(hexValue)
- local tbl = { r = color.r, g = color.g, b = color.b, a = color.a }
- binding.set(tbl)
- postSet(spec, tbl, settingRef)
- end
-
- local defaultTbl = binding.default or {}
- local defaultHex = colorTableToHex(defaultTbl)
-
- local setting =
- Settings.RegisterProxySetting(cat, variable, Settings.VarType.String, spec.name, defaultHex, getter, setter)
- settingRef = setting
-
- local initializer = Settings.CreateColorSwatch(cat, setting, spec.tooltip)
- applyModifiers(initializer, spec)
-
- return initializer, setting
- end
-
- function SB.Input(spec)
- validateSpecFields("input", spec)
-
- local setting, cat = makeProxySetting(spec, Settings.VarType.String, "")
- local data = {
- debounce = spec.debounce,
- maxLetters = spec.maxLetters,
- name = spec.name,
- numeric = spec.numeric,
- onTextChanged = spec.onTextChanged,
- resolveText = spec.resolveText,
- setting = setting,
- settingVariable = getSettingVariable(setting),
- tooltip = spec.tooltip,
- width = spec.width,
- }
-
- local watchVariables = {}
- if spec.watch then
- for _, identifier in ipairs(spec.watch) do
- watchVariables[#watchVariables + 1] = makeVarNameFromIdentifier(identifier)
- end
- end
- if spec.watchVariables then
- for _, variable in ipairs(spec.watchVariables) do
- watchVariables[#watchVariables + 1] = variable
- end
- end
- if #watchVariables > 0 then
- data.watchVariables = watchVariables
- end
-
- local extent = spec.resolveText and 46 or 26
- local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
- local originalInitFrame = initializer.InitFrame
- local originalResetter = initializer.Resetter
-
- initializer._lsbEnabled = true
- initializer.SetEnabled = function(controlInitializer, enabled)
- controlInitializer._lsbEnabled = enabled
- if controlInitializer._lsbActiveFrame then
- applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled)
- end
- end
-
- initializer.InitFrame = function(controlInitializer, frame)
- controlInitializer._lsbActiveFrame = frame
- originalInitFrame(controlInitializer, frame)
- applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false)
- end
-
- initializer.Resetter = function(controlInitializer, frame)
- cancelInputPreviewTimer(frame)
- if frame and frame._lsbInputEditBox then
- if frame._lsbInputEditBox.ClearFocus then
- frame._lsbInputEditBox:ClearFocus()
- end
- frame._lsbInputEditBox._lsbOwnerFrame = nil
- end
- frame._lsbInputData = nil
- frame._lsbInputSetting = nil
- if controlInitializer._lsbActiveFrame == frame then
- controlInitializer._lsbActiveFrame = nil
- end
- originalResetter(controlInitializer, frame)
- end
-
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, spec)
-
- return initializer, setting
- end
-
- --- Creates a proxy setting backed by a custom frame template.
- --- The template's Init receives initializer data containing {setting, name, tooltip}.
- function SB.Custom(spec)
- validateSpecFields("custom", spec)
- assert(spec.template, "Custom: spec.template is required")
- local setting, cat = makeProxySetting(spec, spec.varType or Settings.VarType.String, "")
-
- local initializer =
- Settings.CreateElementInitializer(spec.template, { name = spec.name, tooltip = spec.tooltip })
-
- if initializer.SetSetting then
- initializer:SetSetting(setting)
- end
-
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, spec)
-
- return initializer, setting
- end
-
- --- Unified proxy control dispatch table.
- local DISPATCH = {
- checkbox = "Checkbox",
- slider = "Slider",
- dropdown = "Dropdown",
- color = "Color",
- input = "Input",
- custom = "Custom",
- }
-
- function SB.Control(spec)
- local fn = SB[DISPATCH[spec.type]]
- assert(fn, "Control: unknown type '" .. tostring(spec.type) .. "'")
- return fn(spec)
- end
-
- function SB.Collection(spec)
- assert(spec.height, "Collection: spec.height is required")
-
- local cat = resolveCategory(spec)
- local data = {}
- for key, value in pairs(spec) do
- data[key] = value
- end
-
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
-
- initializer._lsbEnabled = true
- initializer.SetEnabled = function(controlInitializer, enabled)
- controlInitializer._lsbEnabled = enabled
- local activeFrame = controlInitializer._lsbActiveFrame
- if activeFrame then
- if activeFrame.SetAlpha then
- activeFrame:SetAlpha(enabled and 1 or 0.5)
- end
- setCanvasInteractive(activeFrame, enabled)
- end
- end
-
- initializer._lsbRefreshFrame = function(frame)
- applyCollectionFrame(frame, data, initializer)
- initializer.SetEnabled(initializer, initializer._lsbEnabled ~= false)
- end
-
- Settings.RegisterInitializer(cat, initializer)
- registerCategoryRefreshable(cat, initializer)
- applyModifiers(initializer, spec)
-
- return initializer
- end
-
- ----------------------------------------------------------------------------
- -- Composite builders
- ----------------------------------------------------------------------------
-
- function SB.HeightOverrideSlider(sectionPath, spec)
- spec = spec or {}
- local childSpec = {
- path = sectionPath .. ".height",
- name = spec.name or "Height Override",
- tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.",
- min = spec.min or 0,
- max = spec.max or 40,
- step = spec.step or 1,
- getTransform = function(value)
- return value or 0
- end,
- setTransform = function(value)
- return value > 0 and value or nil
- end,
- }
- propagateModifiers(childSpec, spec)
- return SB.Slider(childSpec)
- end
-
- --- Font override group.
- --- Optional spec fields:
- --- fontValues function() -> table (choices for the dropdown)
- --- fontFallback function() -> string (fallback font name)
- --- fontSizeFallback function() -> number (fallback font size)
- --- fontTemplate string (custom template for the font picker)
- function SB.FontOverrideGroup(sectionPath, spec)
- spec = mergeCompositeDefaults("FontOverrideGroup", spec)
- local overridePath = sectionPath .. ".overrideFont"
-
- local enabledSpec = {
- path = overridePath,
- name = spec.enabledName or "Override font",
- tooltip = spec.enabledTooltip or "Override the global font settings for this module.",
- getTransform = function(value)
- return value == true
- end,
- }
- propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
-
- -- Children stay visible but disabled when override is off.
- -- The font picker's SetEnabled hides the preview automatically.
- local outerDisabled = spec.disabled
- local function isOverrideDisabled()
- if outerDisabled and outerDisabled() then
- return true
- end
- return not enabledSetting:GetValue()
- end
-
- local fontSpec = {
- path = sectionPath .. ".font",
- name = spec.fontName or "Font",
- tooltip = spec.fontTooltip,
- values = spec.fontValues,
- disabled = isOverrideDisabled,
- getTransform = function(value)
- if value then
- return value
- end
- if spec.fontFallback then
- return spec.fontFallback()
- end
- return nil
- end,
- }
- propagateModifiers(fontSpec, spec)
-
- local fontInit
- if spec.fontTemplate then
- fontSpec.template = spec.fontTemplate
- fontInit = SB.Custom(fontSpec)
- else
- fontInit = SB.Dropdown(fontSpec)
- end
-
- local sizeSpec = {
- path = sectionPath .. ".fontSize",
- name = spec.sizeName or "Font Size",
- tooltip = spec.sizeTooltip,
- min = spec.sizeMin or 6,
- max = spec.sizeMax or 32,
- step = spec.sizeStep or 1,
- disabled = isOverrideDisabled,
- getTransform = function(value)
- if value then
- return value
- end
- if spec.fontSizeFallback then
- return spec.fontSizeFallback()
- end
- return 11
- end,
- }
- propagateModifiers(sizeSpec, spec)
- local sizeInit = SB.Slider(sizeSpec)
-
- return {
- enabledInit = enabledInit,
- enabledSetting = enabledSetting,
- fontInit = fontInit,
- sizeInit = sizeInit,
- }
- end
-
- function SB.BorderGroup(borderPath, spec)
- spec = spec or {}
-
- local enabledSpec = {
- path = borderPath .. ".enabled",
- name = spec.enabledName or "Show border",
- tooltip = spec.enabledTooltip,
- }
- propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
-
- local thicknessSpec = {
- path = borderPath .. ".thickness",
- name = spec.thicknessName or "Border width",
- tooltip = spec.thicknessTooltip,
- min = spec.thicknessMin or 1,
- max = spec.thicknessMax or 10,
- step = spec.thicknessStep or 1,
- parent = enabledInit,
- parentCheck = function()
- return enabledSetting:GetValue()
- end,
- }
- propagateModifiers(thicknessSpec, spec)
- local thicknessInit = SB.Slider(thicknessSpec)
-
- local colorSpec = {
- path = borderPath .. ".color",
- name = spec.colorName or "Border color",
- tooltip = spec.colorTooltip,
- parent = enabledInit,
- parentCheck = function()
- return enabledSetting:GetValue()
- end,
- }
- propagateModifiers(colorSpec, spec)
- local colorInit = SB.Color(colorSpec)
-
- return {
- enabledInit = enabledInit,
- enabledSetting = enabledSetting,
- thicknessInit = thicknessInit,
- colorInit = colorInit,
- }
- end
-
- function SB.ColorPickerList(basePath, defs, spec)
- spec = spec or {}
- local results = {}
-
- for _, def in ipairs(defs) do
- local childSpec = {
- path = basePath .. "." .. tostring(def.key),
- name = def.name,
- tooltip = def.tooltip,
- }
- propagateModifiers(childSpec, spec)
- local init, setting = SB.Color(childSpec)
- results[#results + 1] = { key = def.key, initializer = init, setting = setting }
- end
-
- return results
- end
-
- function SB.CheckboxList(basePath, defs, spec)
- spec = spec or {}
- local results = {}
-
- for _, def in ipairs(defs) do
- local childSpec = {
- path = basePath .. "." .. tostring(def.key),
- name = def.name,
- tooltip = def.tooltip,
- }
- propagateModifiers(childSpec, spec)
- local init, setting = SB.Checkbox(childSpec)
- results[#results + 1] = { key = def.key, initializer = init, setting = setting }
- end
-
- return results
- end
-
- ----------------------------------------------------------------------------
- -- Utility helpers
- ----------------------------------------------------------------------------
-
- function SB.Header(textOrSpec, category)
- local spec
- if type(textOrSpec) == "table" then
- spec = textOrSpec
- else
- spec = {
- name = textOrSpec,
- category = category,
- }
- end
-
- local cat = resolveCategory(spec)
- local text = spec.name
- local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName)
- local matchesCategoryTitle = catName and text == catName
- local isFirstHeader = not SB._firstHeaderAdded[cat]
- local attachToCategoryHeader = isFirstHeader and matchesCategoryTitle and spec.actions
-
- if isFirstHeader then
- SB._firstHeaderAdded[cat] = true
- if matchesCategoryTitle and not spec.actions then
- return nil
- end
- end
-
- local layout = SB._layouts[cat]
- if spec.actions then
- local height = spec.height
- or (attachToCategoryHeader
- and 1
- or (
- (spec.hideTitle == true or (isFirstHeader and matchesCategoryTitle))
- and 34
- or 50
- ))
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
- _lsbKind = "header",
- name = text,
- actions = spec.actions,
- hideTitle = spec.hideTitle == true or (isFirstHeader and matchesCategoryTitle),
- attachToCategoryHeader = attachToCategoryHeader == true,
- }, height, applyHeaderFrame)
- initializer._lsbRefreshFrame = function(frame)
- applyHeaderFrame(frame, initializer:GetData())
- end
- initializer._lsbResetFrame = hideHeaderActionButtons
- layout:AddInitializer(initializer)
- registerCategoryRefreshable(cat, initializer)
- applyModifiers(initializer, spec)
- return initializer
- end
-
- local initializer = CreateSettingsListSectionHeaderInitializer(text)
- layout:AddInitializer(initializer)
- applyModifiers(initializer, spec)
- return initializer
- end
-
- function SB.Subheader(spec)
- local cat = resolveCategory(spec)
- local layout = SB._layouts[cat]
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
- _lsbKind = "subheader",
- name = spec.name,
- }, 28, applySubheaderFrame)
- layout:AddInitializer(initializer)
- applyModifiers(initializer, spec)
- return initializer
- end
-
- function SB.InfoRow(spec)
- local cat = resolveCategory(spec)
- local layout = SB._layouts[cat]
- local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, {
- _lsbKind = "infoRow",
- name = spec.name,
- value = spec.value,
- wide = spec.wide,
- multiline = spec.multiline,
- }, spec.height or 26, applyInfoRowFrame)
- layout:AddInitializer(initializer)
- initializer._lsbRefreshFrame = function(frame)
- applyInfoRowFrame(frame, initializer:GetData())
- end
- if type(spec.value) == "function" or type(spec.name) == "function" then
- registerCategoryRefreshable(cat, initializer)
- end
- applyModifiers(initializer, spec)
- return initializer
- end
-
- function SB.EmbedCanvas(canvas, height, spec)
- spec = spec or {}
- local cat = spec.category or SB._currentSubcategory or SB._rootCategory
-
- local modifiers = {}
- for k, v in pairs(spec) do
- modifiers[k] = v
- end
- modifiers.canvas = canvas
-
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, {
- _lsbKind = "embedCanvas",
- canvas = canvas,
- }, height or canvas:GetHeight(), applyEmbedCanvasFrame)
-
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, modifiers)
-
- return initializer
- end
-
- -- Make CONFIRM_DIALOG_NAME unique per instance to prevent single-pop collisions
- local CONFIRM_DIALOG_NAME = config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm"
- StaticPopupDialogs[CONFIRM_DIALOG_NAME] = {
- text = "%s",
- button1 = YES,
- button2 = NO,
- OnAccept = function(_, data)
- if data and data.onAccept then
- data.onAccept()
- end
- end,
- timeout = 0,
- whileDead = true,
- hideOnEscape = true,
- }
-
- function SB.Button(spec)
- local cat = spec.category or SB._currentSubcategory or SB._rootCategory
-
- local onClick = spec.onClick
- if spec.confirm then
- local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
- local originalClick = onClick
- onClick = function()
- StaticPopup_Show(CONFIRM_DIALOG_NAME, confirmText, nil, { onAccept = originalClick })
- end
- end
-
- local layout = SB._layouts[cat]
- local initializer =
- CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true)
- layout:AddInitializer(initializer)
- applyModifiers(initializer, spec)
-
- return initializer
- end
-
- function SB.RefreshCategory(categoryOrName)
- local category = categoryOrName
- if type(categoryOrName) == "string" then
- category = SB._subcategories[categoryOrName]
- or (categoryOrName == SB._rootCategoryName and SB._rootCategory)
- end
- if not category then
- return
- end
-
- local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil
- local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category
-
- local refreshables = SB._categoryRefreshables[category] or {}
- for _, initializer in ipairs(refreshables) do
- if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer)
- end
- end
-
- if not isVisible then
- return
- end
-
- local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
- local scrollBox = settingsList and settingsList.ScrollBox
- if scrollBox and scrollBox.ForEachFrame then
- scrollBox:ForEachFrame(function(frame)
- local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer
- if frame.EvaluateState then
- frame:EvaluateState()
- end
- if initializer and initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame(frame, initializer)
- end
- end)
- end
- end
-
- ----------------------------------------------------------------------------
- -- Table-driven registration (AceConfig-inspired)
- ----------------------------------------------------------------------------
-
- local TYPE_ALIASES = {
- toggle = "checkbox",
- range = "slider",
- select = "dropdown",
- execute = "button",
- description = "subheader",
- }
-
- local SPEC_EXCLUDE = { type = true, order = true, defs = true, label = true, condition = true }
-
- -- Composite type dispatch: returns init, setting from a composite builder
- local COMPOSITE_DISPATCH = {
- border = function(path, spec)
- local r = SB.BorderGroup(path, spec)
- return r.enabledInit, r.enabledSetting
- end,
- fontOverride = function(path, spec)
- local r = SB.FontOverrideGroup(path, spec)
- return r.enabledInit, r.enabledSetting
- end,
- heightOverride = function(path, spec)
- return SB.HeightOverrideSlider(path, spec)
- end,
- }
-
- --- Walks an AceConfig-inspired option table and calls the imperative API.
- --- Top-level `onShow`/`onHide` callbacks fire when the page is selected or
- --- navigated away from (via SettingsPanel.SelectCategory hook).
- function SB.RegisterFromTable(tbl)
- assert(tbl.name, "RegisterFromTable: tbl.name is required")
-
- if tbl.rootCategory then
- SB._currentSubcategory = SB._rootCategory
- else
- SB.CreateSubcategory(tbl.name, tbl.parentCategory)
- end
-
- if tbl.onShow or tbl.onHide then
- lib._pageLifecycleCallbacks[SB._currentSubcategory] = {
- onShow = tbl.onShow,
- onHide = tbl.onHide,
- }
- installPageLifecycleHooks()
- end
-
- local groupPath = tbl.path or ""
-
- local function resolvePath(entryPath)
- if not entryPath then
- return groupPath
- end
- if entryPath:find("%.") or groupPath == "" then
- return entryPath
- end
- return groupPath .. "." .. entryPath
- end
-
- if not tbl.args then
- return
- end
-
- -- Sort entries by order field (stable: secondary key breaks ties)
- local sorted = {}
- for key, entry in pairs(tbl.args) do
- sorted[#sorted + 1] = { key = key, entry = entry }
- end
- table.sort(sorted, function(a, b)
- local oa, ob = a.entry.order or 100, b.entry.order or 100
- if oa ~= ob then
- return oa < ob
- end
- return a.key < b.key
- end)
-
- local created = {}
-
- for _, item in ipairs(sorted) do
- local entryKey = item.key
- local entry = item.entry
- local entryType = TYPE_ALIASES[entry.type] or entry.type
-
- -- Evaluate condition (skip entry if false)
- local condition = entry.condition
- local shouldProcess = condition == nil
- or (type(condition) == "function" and condition())
- or (type(condition) ~= "function" and condition)
-
- if shouldProcess then
- local spec = {}
- for k, v in pairs(entry) do
- if not SPEC_EXCLUDE[k] then
- spec[k] = v
- end
- end
-
- if spec.desc and not spec.tooltip then
- spec.tooltip = spec.desc
- spec.desc = nil
- end
-
- if tbl.disabled and spec.disabled == nil then
- spec.disabled = tbl.disabled
- end
- if tbl.hidden and spec.hidden == nil then
- spec.hidden = tbl.hidden
- end
-
- -- Resolve parent string references
- if type(spec.parent) == "string" then
- local ref = created[spec.parent]
- assert(ref, "RegisterFromTable: parent '" .. spec.parent .. "' not found (misspelled or forward-referenced?)")
- spec.parent = ref.initializer
- if spec.parentCheck == "checked" then
- local s = ref.setting
- spec.parentCheck = function()
- return s:GetValue()
- end
- elseif spec.parentCheck == "notChecked" then
- local s = ref.setting
- spec.parentCheck = function()
- return not s:GetValue()
- end
- end
- end
-
- local init, setting
-
- if entryType == "header" then
- init = SB.Header(spec)
- elseif entryType == "subheader" then
- init = SB.Subheader(spec)
- elseif entryType == "info" then
- init = SB.InfoRow(spec)
- elseif entryType == "button" then
- init = SB.Button(spec)
- elseif entryType == "collection" then
- init = SB.Collection(spec)
- elseif entryType == "canvas" then
- init = SB.EmbedCanvas(entry.canvas, entry.height, spec)
- elseif entryType == "colorList" then
- local defs = entry.defs or {}
- if entry.label then
- local labelInit =
- SB.Subheader({ name = entry.label, disabled = spec.disabled, hidden = spec.hidden })
- spec.parent = spec.parent or labelInit
- end
- local results = SB.ColorPickerList(resolvePath(entry.path), defs, spec)
- if results[1] then
- init, setting = results[1].initializer, results[1].setting
- end
- elseif entryType == "toggleList" then
- local defs = entry.defs or {}
- if entry.label then
- local labelInit =
- SB.Subheader({ name = entry.label, disabled = spec.disabled, hidden = spec.hidden })
- spec.parent = spec.parent or labelInit
- end
- local results = SB.CheckboxList(resolvePath(entry.path), defs, spec)
- if results[1] then
- init, setting = results[1].initializer, results[1].setting
- end
- elseif COMPOSITE_DISPATCH[entryType] then
- init, setting = COMPOSITE_DISPATCH[entryType](resolvePath(entry.path), spec)
- elseif DISPATCH[entryType] then
- -- Path mode: resolve path from group prefix
- if not spec.get then
- spec.path = resolvePath(entry.path or spec.path)
- end
- -- Handler mode: fall back to entry key as spec.key if not set
- if spec.get and not spec.key then
- spec.key = entryKey
- end
- spec.type = entryType
- init, setting = SB.Control(spec)
- end
-
- created[entryKey] = { initializer = init, setting = setting }
- end
- end
- end
-
- function SB.RegisterSection(nsTable, key, section)
- nsTable.OptionsSections = nsTable.OptionsSections or {}
- nsTable.OptionsSections[key] = section
- return section
- end
-
- return SB
-end
diff --git a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
new file mode 100644
index 00000000..98207692
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
@@ -0,0 +1,423 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local copyMixin = internal.copyMixin
+local getInitializerData = internal.getInitializerData
+local getOrderedValueEntries = internal.getOrderedValueEntries
+
+local ScrollDropdownMethods = {}
+
+function ScrollDropdownMethods:GetSetting()
+ if self.initializer and self.initializer.GetSetting then
+ return self.initializer:GetSetting()
+ end
+ return self.lsbData and self.lsbData.setting or nil
+end
+
+function ScrollDropdownMethods:RefreshDropdownText(value)
+ local dropdown = self.Control and self.Control.Dropdown
+ if not dropdown then
+ return
+ end
+
+ local setting = self:GetSetting()
+ local currentValue = value
+ if currentValue == nil and setting and setting.GetValue then
+ currentValue = setting:GetValue()
+ end
+
+ local values = self.lsbData and self.lsbData.values
+ if type(values) == "function" then
+ values = values()
+ end
+ local text = values and values[currentValue] or tostring(currentValue or "")
+
+ if dropdown.OverrideText then
+ dropdown:OverrideText(text)
+ elseif dropdown.SetText then
+ dropdown:SetText(text)
+ end
+end
+
+function ScrollDropdownMethods:SetValue(value)
+ self:RefreshDropdownText(value)
+end
+
+function ScrollDropdownMethods:InitDropdown()
+ local setting = self:GetSetting()
+ local data = self.lsbData or {}
+ local scrollHeight = data.scrollHeight or 200
+
+ local dropdown = self.Control and self.Control.Dropdown
+ if not dropdown or not setting then
+ return
+ end
+
+ dropdown:SetupMenu(function(_, rootDescription)
+ rootDescription:SetScrollMode(scrollHeight)
+
+ local values = data.values
+ if type(values) == "function" then
+ values = values()
+ end
+ if not values then
+ return
+ end
+
+ for _, entry in ipairs(getOrderedValueEntries(values)) do
+ rootDescription:CreateRadio(entry.label, function()
+ return setting:GetValue() == entry.value
+ end, function()
+ setting:SetValue(entry.value)
+ self:RefreshDropdownText(entry.value)
+ end, entry.value)
+ end
+ end)
+
+ self:RefreshDropdownText()
+end
+
+local function configureScrollDropdownFrame(frame, initializer)
+ if not frame._lsbOriginalSetValue then
+ frame._lsbOriginalSetValue = frame.SetValue
+ end
+
+ copyMixin(frame, ScrollDropdownMethods)
+ frame.initializer = initializer
+ frame.lsbData = getInitializerData(initializer) or {}
+ initializer._lsbActiveFrame = frame
+ frame:InitDropdown()
+end
+
+if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownControlMixin then
+ hooksecurefunc(SettingsDropdownControlMixin, "Init", function(frame, initializer)
+ local data = getInitializerData(initializer)
+ if not data or data._lsbKind ~= "scrollDropdown" then
+ if frame._lsbOriginalSetValue then
+ frame.SetValue = frame._lsbOriginalSetValue
+ end
+ frame.initializer = initializer
+ frame.lsbData = nil
+ return
+ end
+
+ configureScrollDropdownFrame(frame, initializer)
+ end)
+
+ lib._scrollDropdownHookInstalled = true
+end
+
+local function roundSliderValue(value, step, minValue, maxValue)
+ local actualStep = step or 1
+ local baseValue = minValue or 0
+ local rounded = math.floor(((value - baseValue) / actualStep) + 0.5) * actualStep + baseValue
+ if minValue then
+ rounded = math.max(minValue, rounded)
+ end
+ if maxValue then
+ rounded = math.min(maxValue, rounded)
+ end
+ return rounded
+end
+
+local function getSliderStepCount(minValue, maxValue, step)
+ return math.max(1, math.floor(((maxValue - minValue) / (step or 1)) + 0.5))
+end
+
+local function createInlineSliderFormatters()
+ if not MinimalSliderWithSteppersMixin or not MinimalSliderWithSteppersMixin.Label then
+ return nil
+ end
+
+ return {
+ [MinimalSliderWithSteppersMixin.Label.Right] = function()
+ return ""
+ end,
+ }
+end
+
+local function attachInlineSliderEditor(slider, textLabel, editBoxWidth)
+ if slider._lsbValueButton then
+ return
+ end
+
+ local function hideEditBox()
+ if slider._lsbEditBox and slider._lsbEditBox.ClearFocus then
+ slider._lsbEditBox:ClearFocus()
+ end
+ if slider._lsbEditBox then
+ slider._lsbEditBox:Hide()
+ end
+ if textLabel and textLabel.Show then
+ textLabel:Show()
+ end
+ end
+
+ local function applyEditBoxValue()
+ local editBox = slider._lsbEditBox
+ local enteredValue = editBox and tonumber(editBox:GetText())
+ if enteredValue then
+ local minValue = slider._lsbMinValue or 0
+ local maxValue = slider._lsbMaxValue
+ if slider._lsbRangeResolver then
+ local nextMin, nextMax, nextStep = slider._lsbRangeResolver(enteredValue)
+ if nextMin ~= nil then
+ minValue = nextMin
+ end
+ if nextMax ~= nil then
+ maxValue = nextMax
+ end
+ if nextStep ~= nil then
+ slider._lsbStep = nextStep
+ end
+ if maxValue ~= nil then
+ slider._lsbMaxValue = maxValue
+ end
+ slider._lsbMinValue = minValue
+ end
+
+ slider:SetValue(roundSliderValue(enteredValue, slider._lsbStep, minValue, maxValue))
+ end
+ hideEditBox()
+ end
+
+ local valueButton = CreateFrame("Button", nil, slider)
+ valueButton:RegisterForClicks("LeftButtonDown")
+ valueButton:SetAllPoints(textLabel)
+ slider._lsbValueButton = valueButton
+
+ local editBox = CreateFrame("EditBox", nil, slider, "InputBoxTemplate")
+ editBox:SetAutoFocus(false)
+ editBox:SetNumeric(false)
+ editBox:SetSize(editBoxWidth or 50, 20)
+ editBox:SetPoint("CENTER", textLabel, "CENTER")
+ editBox:SetJustifyH("CENTER")
+ editBox:Hide()
+ slider._lsbEditBox = editBox
+
+ editBox:SetScript("OnEnterPressed", applyEditBoxValue)
+ editBox:SetScript("OnEscapePressed", hideEditBox)
+ editBox:SetScript("OnEditFocusLost", hideEditBox)
+
+ valueButton:SetScript("OnClick", function()
+ editBox:SetText(textLabel and textLabel.GetText and textLabel:GetText() or "")
+ if textLabel and textLabel.Hide then
+ textLabel:Hide()
+ end
+ editBox:Show()
+ editBox:SetFocus()
+ editBox:HighlightText()
+ end)
+end
+
+local function configureInlineSlider(slider, textLabel, field, onValueChanged)
+ local minValue = field.min or 0
+ local maxValue = field.max or 1
+ local step = field.step or 1
+
+ slider._lsbMinValue = minValue
+ slider._lsbMaxValue = maxValue
+ slider._lsbStep = step
+ slider._lsbRangeResolver = field.getRange
+
+ if slider.MinText then
+ slider.MinText:Hide()
+ end
+ if slider.MaxText then
+ slider.MaxText:Hide()
+ end
+ if slider.RightText then
+ slider.RightText:Hide()
+ end
+
+ if slider.Init then
+ slider:Init(minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters())
+ if slider.Slider and slider.Slider.SetValueStep then
+ slider.Slider:SetValueStep(step)
+ end
+ else
+ slider:SetMinMaxValues(minValue, maxValue)
+ slider:SetValueStep(step)
+ slider:SetObeyStepOnDrag(true)
+ end
+
+ attachInlineSliderEditor(slider, textLabel, field.editWidth or 50)
+
+ if not slider._lsbValueChangedBound then
+ local function handleValueChanged(_, value)
+ local rounded = roundSliderValue(value, slider._lsbStep, slider._lsbMinValue, slider._lsbMaxValue)
+ if textLabel and textLabel.SetText then
+ textLabel:SetText(tostring(rounded))
+ end
+ if onValueChanged then
+ onValueChanged(rounded)
+ end
+ end
+
+ if slider.RegisterCallback and MinimalSliderWithSteppersMixin and MinimalSliderWithSteppersMixin.Event then
+ slider:RegisterCallback(MinimalSliderWithSteppersMixin.Event.OnValueChanged, handleValueChanged, slider)
+ else
+ slider:HookScript("OnValueChanged", handleValueChanged)
+ end
+ slider._lsbValueChangedBound = true
+ end
+end
+
+internal.configureInlineSlider = configureInlineSlider
+
+if not lib._sliderHookInstalled then
+ local function setupSliderEditableValue()
+ if not SettingsSliderControlMixin then
+ return
+ end
+
+ local function findValueLabel(sliderWithSteppers)
+ if sliderWithSteppers._label then
+ return sliderWithSteppers._label
+ end
+ if sliderWithSteppers.RightText then
+ return sliderWithSteppers.RightText
+ end
+ if sliderWithSteppers.Label then
+ return sliderWithSteppers.Label
+ end
+ for i = 1, select("#", sliderWithSteppers:GetRegions()) do
+ local region = select(i, sliderWithSteppers:GetRegions())
+ if region and region:IsObjectType("FontString") then
+ return region
+ end
+ end
+ return nil
+ end
+
+ local function getSliderValueText(self)
+ local setting = self and self._lsbCurrentSetting
+ if not setting or not setting.GetValue then
+ return ""
+ end
+ return tostring(setting:GetValue())
+ end
+
+ local function hideSliderEditBox(self)
+ local editBox = self and self._lsbEditBox
+ local valueLabel = self and self._lsbValueLabel
+ if not editBox or not valueLabel then
+ return
+ end
+ editBox:ClearFocus()
+ editBox:Hide()
+ valueLabel:Show()
+ end
+
+ local function applySliderEditValue(self)
+ local editBox = self and self._lsbEditBox
+ local setting = self and self._lsbCurrentSetting
+ local sliderWithSteppers = self and self.SliderWithSteppers
+ if not editBox or not setting or not sliderWithSteppers or not sliderWithSteppers.Slider then
+ hideSliderEditBox(self)
+ return
+ end
+
+ local num = tonumber(editBox:GetText())
+ if num then
+ local slider = sliderWithSteppers.Slider
+ local min, max = slider:GetMinMaxValues()
+ num = math.max(min, math.min(max, num))
+ local step = slider:GetValueStep()
+ if step and step > 0 then
+ num = math.floor(num / step + 0.5) * step
+ end
+ setting:SetValue(num)
+ end
+
+ hideSliderEditBox(self)
+ end
+
+ local function anchorSliderValueButton(self)
+ local valueLabel = self and self._lsbValueLabel
+ local valueButton = self and self._lsbValueButton
+ if not valueLabel or not valueButton then
+ return
+ end
+
+ if valueButton.ClearAllPoints then
+ valueButton:ClearAllPoints()
+ end
+ valueButton:SetAllPoints(valueLabel)
+ end
+
+ hooksecurefunc(SettingsSliderControlMixin, "Init", function(self, initializer)
+ local sliderWithSteppers = self.SliderWithSteppers
+ if not sliderWithSteppers then
+ return
+ end
+
+ local valueLabel = findValueLabel(sliderWithSteppers)
+ if not valueLabel then
+ return
+ end
+
+ self._lsbCurrentSetting = initializer:GetSetting()
+ self._lsbValueLabel = valueLabel
+
+ if not self._lsbValueButton then
+ local btn = CreateFrame("Button", nil, sliderWithSteppers)
+ btn:RegisterForClicks("LeftButtonDown")
+ self._lsbValueButton = btn
+
+ local editBox = CreateFrame("EditBox", nil, sliderWithSteppers, "InputBoxTemplate")
+ editBox:SetAutoFocus(false)
+ editBox:SetNumeric(false)
+ editBox:SetSize(50, 20)
+ editBox:SetPoint("CENTER", valueLabel, "CENTER")
+ editBox:SetJustifyH("CENTER")
+ editBox:Hide()
+ self._lsbEditBox = editBox
+
+ editBox:SetScript("OnEnterPressed", function()
+ applySliderEditValue(self)
+ end)
+ editBox:SetScript("OnEscapePressed", function()
+ hideSliderEditBox(self)
+ end)
+ editBox:SetScript("OnEditFocusLost", function()
+ hideSliderEditBox(self)
+ end)
+
+ btn:SetScript("OnClick", function()
+ local setting = self._lsbCurrentSetting
+ local currentValueLabel = self._lsbValueLabel
+ if not setting or not currentValueLabel then
+ return
+ end
+
+ anchorSliderValueButton(self)
+ editBox:SetText(getSliderValueText(self))
+ currentValueLabel:Hide()
+ editBox:Show()
+ editBox:SetFocus()
+ editBox:HighlightText()
+ end)
+ end
+
+ anchorSliderValueButton(self)
+
+ if self._lsbEditBox and self._lsbEditBox.ClearFocus then
+ self._lsbEditBox:ClearFocus()
+ self._lsbEditBox:Hide()
+ end
+ valueLabel:Show()
+ end)
+ end
+
+ setupSliderEditableValue()
+ lib._sliderHookInstalled = true
+end
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
new file mode 100644
index 00000000..95a3e803
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -0,0 +1,59 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local copyMixin = internal.copyMixin
+
+function lib._installPrimitiveLayout(SB, env)
+ local storeCategory = env.storeCategory
+
+ function SB.CreateRootCategory(name)
+ local category, layout = Settings.RegisterVerticalLayoutCategory(name)
+ SB._rootCategory = category
+ SB._rootCategoryName = name
+ SB._layouts[category] = layout
+ SB._currentSubcategory = nil
+ return category
+ end
+
+ function SB.CreateSubcategory(name, parentCategory)
+ local parent = parentCategory or SB._rootCategory
+ local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
+ SB._currentSubcategory = storeCategory(name, subcategory, layout)
+ return subcategory
+ end
+
+ function SB.CreateCanvasSubcategory(frame, name, parentCategory)
+ local parent = parentCategory or SB._rootCategory
+ local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
+ return storeCategory(name, subcategory, layout)
+ end
+
+ --- Creates a canvas subcategory with a CanvasLayout engine attached.
+ --- Returns a layout object with AddHeader, AddDescription, AddSlider,
+ --- AddColorSwatch, AddButton, AddScrollList methods that position
+ --- controls to match Blizzard's vertical-layout settings pages.
+ ---@param name string Subcategory display name.
+ ---@param parentCategory? table Parent category (defaults to root).
+ ---@return table layout CanvasLayout instance (layout.frame for the raw frame).
+ function SB.CreateCanvasLayout(name, parentCategory)
+ local frame = CreateFrame("Frame", nil)
+ SB.CreateCanvasSubcategory(frame, name, parentCategory)
+ local metrics = copyMixin({}, lib.CanvasLayoutDefaults)
+ return setmetatable({
+ frame = frame,
+ yPos = 0,
+ elements = {},
+ _metrics = metrics,
+ }, { __index = lib.CanvasLayout })
+ end
+
+ return SB
+end
diff --git a/Libs/LibSettingsBuilder/Primitives/Rows.lua b/Libs/LibSettingsBuilder/Primitives/Rows.lua
new file mode 100644
index 00000000..5a39b241
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Primitives/Rows.lua
@@ -0,0 +1,606 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local evaluateStaticOrFunction = internal.evaluateStaticOrFunction
+local registerValueChangedCallback = internal.registerValueChangedCallback
+local setInitializerExtent = internal.setInitializerExtent
+
+local listElementKeysToHide = {
+ "_lsbSubheaderTitle",
+ "_lsbInfoTitle",
+ "_lsbInfoValue",
+ "_lsbCanvas",
+ "_lsbInputTitle",
+ "_lsbInputEditBox",
+ "_lsbInputPreview",
+}
+
+local function resetListElement(frame)
+ for _, key in ipairs(listElementKeysToHide) do
+ local region = frame[key]
+ if region then
+ region:Hide()
+ end
+ end
+end
+
+local function hideListElementObjects(frame, getterName)
+ if not frame or not frame[getterName] then
+ return
+ end
+
+ local objects = { frame[getterName](frame) }
+ for i = 1, #objects do
+ local object = objects[i]
+ if object and object.Hide then
+ object:Hide()
+ end
+ end
+end
+
+local function resetPlainListElementFrame(frame)
+ hideListElementObjects(frame, "GetChildren")
+ hideListElementObjects(frame, "GetRegions")
+ resetListElement(frame)
+end
+
+local function ensureSubheaderTitle(frame)
+ if frame._lsbSubheaderTitle then
+ return frame._lsbSubheaderTitle
+ end
+
+ local title = lib.CreateSubheaderTitle(frame)
+ frame._lsbSubheaderTitle = title
+ frame.Title = title
+ return title
+end
+
+local function ensureInfoRowWidgets(frame)
+ if frame._lsbInfoTitle and frame._lsbInfoValue then
+ return frame._lsbInfoTitle, frame._lsbInfoValue
+ end
+
+ local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ title:SetPoint("LEFT", 37, 0)
+ title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
+ title:SetJustifyH("LEFT")
+
+ local value = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
+ value:SetPoint("LEFT", frame, "CENTER", -80, 0)
+ value:SetJustifyH("LEFT")
+
+ frame._lsbInfoTitle = title
+ frame._lsbInfoValue = value
+ frame.Title = title
+ frame.Value = value
+
+ return title, value
+end
+
+local function ensureHeaderRowWidgets(frame)
+ if frame._lsbHeaderTitle then
+ return frame
+ end
+
+ frame._lsbHeaderTitle = lib.CreateHeaderTitle(frame)
+ frame._lsbHeaderActionButtons = frame._lsbHeaderActionButtons or {}
+
+ return frame
+end
+
+local function getSettingsListHeader()
+ local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ return settingsList and settingsList.Header or nil
+end
+
+local function hideHeaderActionButtons(frame)
+ for _, button in ipairs(frame._lsbHeaderActionButtons or {}) do
+ button:SetScript("OnClick", nil)
+ button:SetScript("OnEnter", nil)
+ button:SetScript("OnLeave", nil)
+ button:Hide()
+ end
+end
+
+local function applyHeaderActionButtons(frame, actions, actionParent, rightAnchor)
+ ensureHeaderRowWidgets(frame)
+ local buttons = frame._lsbHeaderActionButtons
+ local anchor = nil
+ local visibleCount = 0
+
+ actionParent = actionParent or frame
+ hideHeaderActionButtons(frame)
+
+ for _, action in ipairs(actions or {}) do
+ if not evaluateStaticOrFunction(action.hidden, action, frame) then
+ visibleCount = visibleCount + 1
+
+ local button = buttons[visibleCount]
+ if button and button._lsbActionParent ~= actionParent then
+ button:Hide()
+ button = nil
+ end
+ if not button then
+ button = CreateFrame("Button", nil, actionParent, "UIPanelButtonTemplate")
+ button._lsbActionParent = actionParent
+ buttons[visibleCount] = button
+ end
+
+ button:ClearAllPoints()
+ if anchor then
+ button:SetPoint("RIGHT", anchor, "LEFT", -8, 0)
+ elseif rightAnchor then
+ button:SetPoint("RIGHT", rightAnchor, "LEFT", -8, 0)
+ else
+ button:SetPoint("RIGHT", actionParent, "RIGHT", -20, 0)
+ end
+ button:SetSize(action.width or 100, action.height or 22)
+ button:SetText(action.text or action.name or "")
+ if button.SetEnabled then
+ local enabled = evaluateStaticOrFunction(action.enabled, action, frame)
+ if enabled == nil then
+ enabled = true
+ end
+ button:SetEnabled(enabled)
+ end
+ internal.setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, frame))
+ button:SetScript("OnClick", function()
+ if action.onClick then
+ action.onClick(action, frame)
+ end
+ end)
+ button:Show()
+ anchor = button
+ end
+ end
+end
+
+local function applySubheaderFrame(frame, data)
+ local title = ensureSubheaderTitle(frame)
+ title:SetText(data.name)
+ title:Show()
+end
+
+local function applyHeaderFrame(frame, data)
+ ensureHeaderRowWidgets(frame)
+ local settingsHeader = data.attachToCategoryHeader and getSettingsListHeader() or nil
+ local actionParent = settingsHeader or frame
+ local rightAnchor = settingsHeader and settingsHeader.DefaultsButton or nil
+
+ if frame._lsbHeaderTitle then
+ frame._lsbHeaderTitle:ClearAllPoints()
+ frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
+ frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ frame._lsbHeaderTitle:SetText(data.name or "")
+ end
+
+ applyHeaderActionButtons(frame, data.actions, actionParent, rightAnchor)
+
+ if frame._lsbHeaderTitle then
+ if data.hideTitle then
+ frame._lsbHeaderTitle:Hide()
+ return
+ end
+
+ local titleRight = -20
+ local buttons = frame._lsbHeaderActionButtons or {}
+ for i = 1, #buttons do
+ local button = buttons[i]
+ if button and button.IsShown and button:IsShown() then
+ frame._lsbHeaderTitle:ClearAllPoints()
+ frame._lsbHeaderTitle:SetPoint("TOPLEFT", frame, "TOPLEFT", 7, -16)
+ frame._lsbHeaderTitle:SetPoint("RIGHT", button, "LEFT", -12, 0)
+ titleRight = nil
+ break
+ end
+ end
+ if titleRight then
+ frame._lsbHeaderTitle:SetPoint("RIGHT", frame, "RIGHT", titleRight, 0)
+ end
+ frame._lsbHeaderTitle:Show()
+ end
+end
+
+local function applyInfoRowFrame(frame, data)
+ local title, value = ensureInfoRowWidgets(frame)
+ local name = evaluateStaticOrFunction(data.name, frame, data)
+ local resolvedValue = evaluateStaticOrFunction(data.value, frame, data)
+ local isWide = data.wide == true or name == nil or name == ""
+ local isMultiline = data.multiline == true
+
+ title:ClearAllPoints()
+ value:ClearAllPoints()
+
+ if isWide then
+ title:SetText("")
+ title:Hide()
+ value:SetPoint("TOPLEFT", frame, "TOPLEFT", 37, isMultiline and -4 or 0)
+ value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ else
+ title:SetText(name or "")
+ title:SetPoint("LEFT", 37, 0)
+ title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
+ title:Show()
+ value:SetPoint("LEFT", frame, "CENTER", -80, 0)
+ value:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ end
+
+ if value.SetWordWrap then
+ value:SetWordWrap(isMultiline)
+ end
+ if value.SetJustifyV then
+ value:SetJustifyV(isMultiline and "TOP" or "MIDDLE")
+ end
+ if value.SetJustifyH then
+ value:SetJustifyH("LEFT")
+ end
+ value:SetText(resolvedValue or "")
+ if not isWide then
+ title:Show()
+ end
+ value:Show()
+end
+
+local function ensureInputRowWidgets(frame)
+ if frame._lsbInputTitle and frame._lsbInputEditBox and frame._lsbInputPreview then
+ return frame._lsbInputTitle, frame._lsbInputEditBox, frame._lsbInputPreview
+ end
+
+ local title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
+ title:SetJustifyH("LEFT")
+ title:SetWordWrap(false)
+
+ local editBox = CreateFrame("EditBox", nil, frame, "InputBoxTemplate")
+ editBox:SetAutoFocus(false)
+
+ local preview = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ preview:SetJustifyH("LEFT")
+ preview:SetJustifyV("TOP")
+ preview:SetWordWrap(false)
+ preview:Hide()
+
+ frame._lsbInputTitle = title
+ frame._lsbInputEditBox = editBox
+ frame._lsbInputPreview = preview
+ frame.Title = title
+ frame.EditBox = editBox
+ frame.Preview = preview
+
+ return title, editBox, preview
+end
+
+local function setInputPreviewText(frame, text)
+ local preview = frame._lsbInputPreview
+ if not preview then
+ return
+ end
+
+ text = text and tostring(text) or ""
+ preview:SetText(text)
+ if text ~= "" then
+ preview:Show()
+ else
+ preview:Hide()
+ end
+end
+
+local function cancelInputPreviewTimer(frame)
+ local timer = frame and frame._lsbInputPreviewTimer
+ if timer and timer.Cancel then
+ timer:Cancel()
+ end
+ if frame then
+ frame._lsbInputPreviewTimer = nil
+ end
+end
+
+local function syncInputRowText(frame, value)
+ local editBox = frame and frame._lsbInputEditBox
+ if not editBox then
+ return
+ end
+
+ value = value == nil and "" or tostring(value)
+ if editBox.GetText and editBox:GetText() == value then
+ return
+ end
+
+ frame._lsbUpdatingInputText = true
+ editBox:SetText(value)
+ frame._lsbUpdatingInputText = nil
+end
+
+local function resolveInputPreview(frame)
+ local data = frame and frame._lsbInputData
+ local setting = frame and frame._lsbInputSetting
+ if not data or not data.resolveText then
+ setInputPreviewText(frame, nil)
+ return
+ end
+
+ local value = setting and setting.GetValue and setting:GetValue() or nil
+ setInputPreviewText(frame, data.resolveText(value, setting, frame))
+end
+
+local function scheduleInputPreview(frame, immediate)
+ cancelInputPreviewTimer(frame)
+
+ local data = frame and frame._lsbInputData
+ if not data or not data.resolveText then
+ setInputPreviewText(frame, nil)
+ return
+ end
+
+ local delay = immediate and 0 or (data.debounce or 0)
+ if delay > 0 and C_Timer and C_Timer.NewTimer then
+ frame._lsbInputPreviewTimer = C_Timer.NewTimer(delay, function()
+ frame._lsbInputPreviewTimer = nil
+ resolveInputPreview(frame)
+ end)
+ return
+ end
+
+ resolveInputPreview(frame)
+end
+
+local function applyInputRowEnabledState(frame, enabled)
+ if not frame then
+ return
+ end
+
+ if frame.SetAlpha then
+ frame:SetAlpha(enabled and 1 or 0.5)
+ end
+
+ local editBox = frame._lsbInputEditBox
+ if not editBox then
+ return
+ end
+
+ if editBox.SetEnabled then
+ editBox:SetEnabled(enabled)
+ end
+ if editBox.EnableMouse then
+ editBox:EnableMouse(enabled)
+ end
+end
+
+local function applyInputRowFrame(frame, data)
+ local title, editBox, preview = ensureInputRowWidgets(frame)
+ local hasPreview = data.resolveText ~= nil
+
+ title:ClearAllPoints()
+ title:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, hasPreview and "TOPLEFT" or "LEFT", 37, hasPreview and -6 or 0)
+ title:SetPoint("RIGHT", frame, "CENTER", -85, 0)
+ title:SetJustifyV(hasPreview and "TOP" or "MIDDLE")
+ title:SetText(data.name)
+ title:Show()
+
+ editBox:ClearAllPoints()
+ editBox:SetPoint(hasPreview and "TOPLEFT" or "LEFT", frame, "CENTER", -80, hasPreview and -2 or 0)
+ editBox:SetSize(data.width or 140, 20)
+ if editBox.SetNumeric then
+ editBox:SetNumeric(data.numeric == true)
+ end
+ if editBox.SetMaxLetters and data.maxLetters then
+ editBox:SetMaxLetters(data.maxLetters)
+ end
+ if editBox.SetTextInsets then
+ editBox:SetTextInsets(6, 6, 0, 0)
+ end
+ editBox:Show()
+
+ preview:ClearAllPoints()
+ preview:SetPoint("TOPLEFT", editBox, "BOTTOMLEFT", 0, -3)
+ preview:SetPoint("RIGHT", frame, "RIGHT", -20, 0)
+ if hasPreview then
+ preview:Show()
+ else
+ preview:Hide()
+ end
+
+ frame._lsbInputData = data
+ frame._lsbInputSetting = data.setting
+ editBox._lsbOwnerFrame = frame
+
+ if not editBox._lsbInputScriptsBound then
+ editBox:SetScript("OnTextChanged", function(self)
+ local owner = self._lsbOwnerFrame
+ if not owner or owner._lsbUpdatingInputText then
+ return
+ end
+
+ local setting = owner._lsbInputSetting
+ local text = self:GetText() or ""
+ if setting and setting.SetValue then
+ setting:SetValue(text)
+ end
+
+ local inputData = owner._lsbInputData
+ if inputData and inputData.onTextChanged then
+ inputData.onTextChanged(text, setting, owner)
+ end
+
+ scheduleInputPreview(owner, false)
+ end)
+ editBox:SetScript("OnEnterPressed", function(self)
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ end)
+ editBox:SetScript("OnEscapePressed", function(self)
+ local owner = self._lsbOwnerFrame
+ if owner then
+ local setting = owner._lsbInputSetting
+ local value = setting and setting.GetValue and setting:GetValue() or ""
+ syncInputRowText(owner, value)
+ scheduleInputPreview(owner, true)
+ end
+ if self.ClearFocus then
+ self:ClearFocus()
+ end
+ end)
+ editBox._lsbInputScriptsBound = true
+ end
+
+ syncInputRowText(frame, data.setting and data.setting.GetValue and data.setting:GetValue() or "")
+
+ local ownVariable = data.settingVariable
+ registerValueChangedCallback(frame, ownVariable, function()
+ local currentSetting = frame._lsbInputSetting
+ local value = currentSetting and currentSetting.GetValue and currentSetting:GetValue() or ""
+ syncInputRowText(frame, value)
+ end, frame)
+
+ if data.watchVariables then
+ for _, variable in ipairs(data.watchVariables) do
+ if variable ~= ownVariable then
+ registerValueChangedCallback(frame, variable, function()
+ scheduleInputPreview(frame, true)
+ end, frame)
+ end
+ end
+ end
+
+ scheduleInputPreview(frame, true)
+end
+
+local function applyEmbedCanvasFrame(frame, data, initializer)
+ local canvas = data.canvas
+ if not canvas then
+ return
+ end
+
+ frame._lsbCanvas = canvas
+ canvas:SetParent(frame)
+ canvas:ClearAllPoints()
+ canvas:SetPoint("TOPLEFT", 0, 0)
+ canvas:SetPoint("TOPRIGHT", 0, 0)
+ canvas:SetHeight(initializer:GetExtent())
+ canvas:Show()
+end
+
+local function ensureListElementCallbackHandles(frame)
+ if frame.cbrHandles or not (Settings and Settings.CreateCallbackHandleContainer) then
+ return
+ end
+
+ frame.cbrHandles = Settings.CreateCallbackHandleContainer()
+end
+
+local function initializerShouldShow(initializer)
+ if initializer and initializer.ShouldShow then
+ return initializer:ShouldShow()
+ end
+
+ if initializer and initializer._shownPredicates then
+ for _, predicate in ipairs(initializer._shownPredicates) do
+ if not predicate() then
+ return false
+ end
+ end
+ end
+
+ return true
+end
+
+local function initializerIsEnabled(initializer)
+ if initializer and initializer.EvaluateModifyPredicates then
+ return initializer:EvaluateModifyPredicates()
+ end
+
+ if initializer and initializer._modifyPredicates then
+ for _, predicate in ipairs(initializer._modifyPredicates) do
+ if not predicate() then
+ return false
+ end
+ end
+ end
+
+ return true
+end
+
+local function createCustomListRowInitializer(template, data, extent, initFrame)
+ local initializer = Settings.CreateElementInitializer(template, data)
+ setInitializerExtent(initializer, extent)
+
+ initializer.InitFrame = function(self, frame)
+ ensureListElementCallbackHandles(frame)
+
+ frame.data = self.data
+ if frame.Text then
+ frame.Text:SetText("")
+ end
+ if frame.NewFeature then
+ frame.NewFeature:Hide()
+ end
+
+ resetPlainListElementFrame(frame)
+ initFrame(frame, self.data, self)
+ self._lsbActiveFrame = frame
+
+ if not frame._lsbHasCustomEvaluateState then
+ frame.EvaluateState = function(control)
+ local currentInitializer = control.GetElementData and control:GetElementData()
+ or control._lsbInitializer
+ if currentInitializer and currentInitializer.SetEnabled then
+ currentInitializer:SetEnabled(initializerIsEnabled(currentInitializer))
+ end
+ control:SetShown(initializerShouldShow(currentInitializer))
+ end
+ frame._lsbHasCustomEvaluateState = true
+ end
+
+ frame._lsbInitializer = self
+ frame:EvaluateState()
+ end
+
+ initializer.Resetter = function(self, frame)
+ if frame.cbrHandles and frame.cbrHandles.Unregister then
+ frame.cbrHandles:Unregister()
+ end
+ if frame.Text then
+ frame.Text:SetText("")
+ end
+ if frame.NewFeature then
+ frame.NewFeature:Hide()
+ end
+ -- Canvas rows are recycled by Blizzard's list view. Hide and detach the
+ -- previous row-owned canvas here so it cannot remain visible on the
+ -- wrong page when the frame is reused for a different initializer.
+ if frame._lsbCanvas then
+ frame._lsbCanvas:Hide()
+ frame._lsbCanvas = nil
+ end
+ if self._lsbActiveFrame == frame then
+ self._lsbActiveFrame = nil
+ end
+ if self._lsbResetFrame then
+ self._lsbResetFrame(frame, self)
+ end
+
+ resetPlainListElementFrame(frame)
+ frame.data = nil
+ frame._lsbInitializer = nil
+ end
+
+ return initializer
+end
+
+internal.hideHeaderActionButtons = hideHeaderActionButtons
+internal.applyHeaderFrame = applyHeaderFrame
+internal.applySubheaderFrame = applySubheaderFrame
+internal.applyInfoRowFrame = applyInfoRowFrame
+internal.applyInputRowFrame = applyInputRowFrame
+internal.applyInputRowEnabledState = applyInputRowEnabledState
+internal.cancelInputPreviewTimer = cancelInputPreviewTimer
+internal.applyEmbedCanvasFrame = applyEmbedCanvasFrame
+internal.createCustomListRowInitializer = createCustomListRowInitializer
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index a00b306b..e375e61c 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -7,9 +7,9 @@ It supports:
- path-based bindings for AceDB-style profile tables,
- handler-mode bindings for arbitrary storage,
- built-in text input rows with optional debounced preview resolution,
-- first-class dynamic collections for scrollable or sectioned list editors,
+- first-class dynamic lists and sectioned editors,
- composite builders for common settings groups,
-- layout-only rows such as headers, subheaders, info rows, buttons, and embedded canvases,
+- canonical row types for headers, subheaders, info rows, buttons, canvases, and page actions,
- XML/template-backed custom controls when a built-in row is not enough,
- category refresh hooks for out-of-band state changes,
- deterministic dropdown ordering,
@@ -21,12 +21,12 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
| Need | LibSettingsBuilder |
|---|---|
-| Standard settings pages | `RegisterFromTable(...)` |
+| Standard settings pages | `RegisterPage(...)` |
| Fine-grained control | imperative `SB.Checkbox(...)`, `SB.Slider(...)`, `SB.Input(...)`, etc. |
| Existing AceDB profiles | `PathAdapter(...)` |
| Custom storage | handler mode with `get` / `set` / `key` |
| Text entry / numeric ID fields | `SB.Input(...)` or `type = "input"` |
-| Dynamic editors / ordered lists | `SB.Collection(...)` or `type = "collection"` |
+| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
| Reusable settings groups | border, font override, positioning composites |
| XML-backed bespoke widgets | `SB.Custom(...)` |
| Force visible rows to refresh | `SB.RefreshCategory(...)` |
@@ -53,24 +53,22 @@ local SB = LSB:New({
SB.CreateRootCategory("My Addon")
-SB.RegisterFromTable({
+SB.RegisterPage({
name = "General",
path = "general",
- args = {
- enabled = {
- type = "toggle",
+ rows = {
+ {
+ type = "checkbox",
path = "enabled",
name = "Enable",
- order = 1,
},
- opacity = {
- type = "range",
+ {
+ type = "slider",
path = "opacity",
name = "Opacity",
min = 0,
max = 100,
step = 1,
- order = 2,
},
},
})
@@ -78,26 +76,28 @@ SB.RegisterFromTable({
SB.RegisterCategories()
```
-## Supported `RegisterFromTable` types
+## Canonical row types
-The table API understands both AceConfig-style aliases and library-specific row types.
+The page API accepts canonical row types only.
| Type | Meaning |
|---|---|
-| `toggle` | Alias for a checkbox proxy setting |
-| `range` | Alias for a slider proxy setting |
-| `select` | Alias for a dropdown proxy setting |
+| `checkbox` | Boolean proxy setting |
+| `slider` | Numeric proxy setting |
+| `dropdown` | Deterministic menu proxy setting |
| `input` | Built-in text input row with optional preview / debounce support |
| `color` | Color swatch proxy setting |
-| `execute` | Alias for a button row |
+| `button` | Button row |
| `header` | Blizzard-style section header |
-| `description` | Alias for a subheader row |
+| `subheader` | Secondary text row |
| `info` | Left-label / right-value informational row |
| `canvas` | Embedded frame row for canvas content |
-| `collection` | First-class dynamic list/section widget |
+| `pageActions` | Right-aligned category-header action row |
+| `list` | First-class dynamic flat list widget |
+| `sectionList` | First-class dynamic grouped list widget |
| `custom` | Proxy setting backed by a custom XML template |
| `colorList` | Expands `defs` into multiple color swatches |
-| `toggleList` | Expands `defs` into multiple checkboxes |
+| `checkboxList` | Expands `defs` into multiple checkboxes |
| `border` | Composite group for border enable / width / color |
| `fontOverride` | Composite group for override toggle, font picker, and size slider |
| `heightOverride` | Composite slider with nil/zero transforms |
@@ -145,10 +145,10 @@ spellId = {
The library has three main implementation paths:
- **Proxy controls** — `checkbox`, `slider`, `dropdown`, `color`, `input`, and `custom` all go through the same proxy-setting pipeline. That means path mode and handler mode work consistently across them.
-- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `collection` are initializer/layout helpers rather than persisted settings.
-- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `toggleList` expand into multiple child controls.
+- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `pageActions` are initializer/layout helpers rather than persisted settings.
+- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `checkboxList` expand into multiple child controls.
-`header` supports optional `actions = { ... }` for right-aligned page or section buttons, and `collection` covers the common "custom canvas page" cases without making authors drop into a second authoring API. Use `canvas` and `custom` as escape hatches, not the default path.
+Use `pageActions` for right-aligned page buttons. Use `list` and `sectionList` for dynamic editors, and keep `canvas` / `custom` as escape hatches for truly bespoke frames. Canvas rows stay on the existing lifecycle path, so page switches continue to reuse the same proven frame handling.
`input` specifically is implemented as a built-in custom list row using `SettingsListElementTemplate`, with an `InputBoxTemplate` edit box anchored in the standard left-label / right-control layout. It does **not** need a separate XML template the way `custom` controls do.
@@ -184,8 +184,9 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr
- Embed the library inside your addon's `Libs/` folder.
- Load `LibStub` before `LibSettingsBuilder`.
-- Prefer one `RegisterFromTable(...)` DSL for both simple rows and complex editors.
-- `SB.RefreshCategory(...)` is the intended way to refresh dynamic info rows, dropdown options, and collections after profile mutations, async item loads, or other out-of-band changes.
+- Load `Libs\LibSettingsBuilder\embed.xml` rather than the individual library Lua files.
+- Prefer `RegisterPage(...)` for declarative pages and the imperative builders for fine-grained control.
+- `SB.RefreshCategory(...)` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes.
- Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks.
## License
diff --git a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
similarity index 77%
rename from Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
rename to Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index ce2f9b1b..54d2be6c 100644
--- a/Libs/LibSettingsBuilder/Tests/LibSettingsBuilder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -86,6 +86,7 @@ describe("LibSettingsBuilder", function()
local function createScriptableFrame()
local frame = TestHelpers.makeFrame()
frame._scripts = {}
+ frame._hooks = {}
frame._text = ""
frame._focused = false
frame.RegisterEvent = function() end
@@ -93,6 +94,15 @@ describe("LibSettingsBuilder", function()
frame.RegisterForClicks = function(self, ...)
self._registeredClicks = { ... }
end
+ frame.HookScript = function(self, event, fn)
+ self._hooks[event] = self._hooks[event] or {}
+ self._hooks[event][#self._hooks[event] + 1] = fn
+ end
+ frame.RunHookScript = function(self, event, ...)
+ for _, fn in ipairs(self._hooks[event] or {}) do
+ fn(self, ...)
+ end
+ end
frame.SetScript = function(self, event, fn)
self._scripts[event] = fn
end
@@ -156,7 +166,7 @@ describe("LibSettingsBuilder", function()
return frame
end
- TestHelpers.LoadChunk("Libs/LibSettingsBuilder/LibSettingsBuilder.lua", "Unable to load LibSettingsBuilder.lua")()
+ TestHelpers.LoadLibSettingsBuilder()
return hooks, LibStub("LibSettingsBuilder-1.0")
end
@@ -237,7 +247,7 @@ describe("LibSettingsBuilder", function()
end
-- Load the library
- TestHelpers.LoadChunk("Libs/LibSettingsBuilder/LibSettingsBuilder.lua", "Unable to load LibSettingsBuilder.lua")()
+ TestHelpers.LoadLibSettingsBuilder()
-- Register LSMW stub
local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1)
@@ -845,6 +855,71 @@ describe("LibSettingsBuilder", function()
assert.are.equal(embedFrame, canvas:GetParent())
end)
+ it("canvas rows hide the previous canvas when the frame is reused on another page", function()
+ local function makeListElementFrame()
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ fontString.SetJustifyH = function() end
+ fontString.SetJustifyV = function() end
+ return fontString
+ end
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+ return frame
+ end
+
+ local canvasA = createScriptableFrame()
+ canvasA.SetParent = function(self, parent)
+ self._parent = parent
+ end
+ canvasA.GetParent = function(self)
+ return self._parent
+ end
+
+ local canvasB = createScriptableFrame()
+ canvasB.SetParent = function(self, parent)
+ self._parent = parent
+ end
+ canvasB.GetParent = function(self)
+ return self._parent
+ end
+
+ local canvasRowA = SB.EmbedCanvas(canvasA, 120)
+ local canvasRowB = SB.EmbedCanvas(canvasB, 140)
+ local subheader = SB.Subheader({ name = "Reused Frame" })
+ local frame = makeListElementFrame()
+
+ canvasRowA:InitFrame(frame)
+ assert.is_true(canvasA:IsShown())
+ assert.are.equal(frame, canvasA:GetParent())
+
+ canvasRowA:Resetter(frame)
+ assert.is_false(canvasA:IsShown())
+ assert.is_nil(frame._lsbCanvas)
+
+ subheader:InitFrame(frame)
+ assert.is_false(canvasA:IsShown())
+
+ subheader:Resetter(frame)
+
+ canvasRowB:InitFrame(frame)
+ assert.is_false(canvasA:IsShown())
+ assert.is_true(canvasB:IsShown())
+ assert.are.equal(frame, canvasB:GetParent())
+
+ canvasRowB:Resetter(frame)
+ assert.is_false(canvasB:IsShown())
+
+ canvasRowA:InitFrame(frame)
+ assert.is_true(canvasA:IsShown())
+ assert.are.equal(frame, canvasA:GetParent())
+ end)
+
-- Button
it("Button creates button initializer with onClick", function()
local clicked = false
@@ -1239,15 +1314,11 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Display", init._text)
end)
- -- Header first-header suppression
- it("Header suppresses first header matching subcategory name", function()
+ it("Header matching subcategory name still returns a normal header", function()
SB.CreateSubcategory("Appearance")
local init = SB.Header("Appearance")
- assert.is_nil(init)
-
- -- Second header with different text is not suppressed
- local init2 = SB.Header("Colors")
- assert.is_not_nil(init2)
+ assert.are.equal("header", init._type)
+ assert.are.equal("Appearance", init._text)
end)
it("Header accepts hidden predicates through spec tables", function()
@@ -1464,18 +1535,18 @@ describe("LibSettingsBuilder", function()
})
end)
- -- RegisterFromTable
- it("RegisterFromTable creates subcategory and controls from table", function()
+ -- RegisterPage
+ it("RegisterPage creates subcategory and controls from ordered rows", function()
local SB2 = createSB2("TBL1", "TableTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Test Section",
path = "global",
- args = {
- header1 = { type = "header", name = "Visibility", order = 1 },
- mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 2 },
- val = { type = "range", path = "value", name = "Value", min = 0, max = 10, step = 1, order = 3 },
- mode = { type = "select", path = "mode", name = "Mode", values = { solid = "Solid" }, order = 4 },
+ rows = {
+ { id = "header1", type = "header", name = "Visibility" },
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
+ { id = "val", type = "slider", path = "value", name = "Value", min = 0, max = 10, step = 1 },
+ { id = "mode", type = "dropdown", path = "mode", name = "Mode", values = { solid = "Solid" } },
},
})
@@ -1483,18 +1554,18 @@ describe("LibSettingsBuilder", function()
assert.is_not_nil(SB2.GetSubcategoryID("Test Section"))
end)
- it("RegisterFromTable inherits disabled from group", function()
+ it("RegisterPage inherits disabled from the page", function()
local disabledFn = function()
return true
end
local SB2 = createSB2("TBL2", "InheritTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Inherit Section",
path = "global",
disabled = disabledFn,
- args = {
- mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 1 },
+ rows = {
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
},
})
@@ -1503,17 +1574,18 @@ describe("LibSettingsBuilder", function()
assert.is_not_nil(SB2.GetSubcategoryID("Inherit Section"))
end)
- it("RegisterFromTable resolves parent references by key", function()
+ it("RegisterPage resolves parent references by row id", function()
local SB2 = createSB2("TBL3", "ParentRefTest")
assert.has_no.errors(function()
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Parent Ref Section",
path = "global",
- args = {
- parentCtrl = { type = "toggle", path = "hideWhenMounted", name = "Parent", order = 1 },
- childCtrl = {
- type = "range",
+ rows = {
+ { id = "parentCtrl", type = "checkbox", path = "hideWhenMounted", name = "Parent" },
+ {
+ id = "childCtrl",
+ type = "slider",
path = "value",
name = "Child",
min = 0,
@@ -1521,34 +1593,32 @@ describe("LibSettingsBuilder", function()
step = 1,
parent = "parentCtrl",
parentCheck = "checked",
- order = 2,
},
},
})
end)
end)
- it("RegisterFromTable supports type aliases", function()
+ it("RegisterPage accepts canonical row types only", function()
local SB2 = createSB2("TBL4", "AliasTest")
- -- All AceConfig type aliases should work without error
assert.has_no.errors(function()
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Alias Section",
path = "global",
- args = {
- t = { type = "toggle", path = "hideWhenMounted", name = "Toggle", order = 1 },
- r = { type = "range", path = "value", name = "Range", min = 0, max = 10, step = 1, order = 2 },
- s = { type = "select", path = "mode", name = "Select", values = { solid = "Solid" }, order = 3 },
- h = { type = "header", name = "Header", order = 4 },
- d = { type = "description", name = "Desc", order = 5 },
- i = { type = "info", name = "Author", value = "Test", order = 6 },
+ rows = {
+ { id = "t", type = "checkbox", path = "hideWhenMounted", name = "Toggle" },
+ { id = "r", type = "slider", path = "value", name = "Range", min = 0, max = 10, step = 1 },
+ { id = "s", type = "dropdown", path = "mode", name = "Select", values = { solid = "Solid" } },
+ { id = "h", type = "header", name = "Header" },
+ { id = "d", type = "subheader", name = "Desc" },
+ { id = "i", type = "info", name = "Author", value = "Test" },
},
})
end)
end)
- it("RegisterFromTable supports desc as alias for tooltip", function()
+ it("RegisterPage supports desc as alias for tooltip", function()
local capturedTooltip
local settings = Settings
local origCreateCheckbox = settings.CreateCheckbox
@@ -1559,16 +1629,16 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("TBL5", "DescTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Desc Section",
path = "global",
- args = {
- mounted = {
- type = "toggle",
+ rows = {
+ {
+ id = "mounted",
+ type = "checkbox",
path = "hideWhenMounted",
name = "Hide",
desc = "Hide when on a mount.",
- order = 1,
},
},
})
@@ -1577,14 +1647,14 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Hide when on a mount.", capturedTooltip)
end)
- it("RegisterFromTable path prefixing works", function()
+ it("RegisterPage path prefixing works", function()
local SB2 = createSB2("TBL7", "PrefixTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Prefix Section",
path = "powerBar",
- args = {
- enabled = { type = "toggle", path = "enabled", name = "Enabled", order = 1 },
+ rows = {
+ { id = "enabled", type = "checkbox", path = "enabled", name = "Enabled" },
},
})
@@ -1592,8 +1662,8 @@ describe("LibSettingsBuilder", function()
assert.is_true(addonNS.Addon.db.profile.powerBar.enabled)
end)
- -- RegisterFromTable condition support
- it("RegisterFromTable condition=false skips entry", function()
+ -- RegisterPage condition support
+ it("RegisterPage condition=false skips entry", function()
local headerCreated = false
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1605,19 +1675,19 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND1", "CondTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Cond Section",
path = "global",
- args = {
- skipped = {
+ rows = {
+ {
+ id = "skipped",
type = "header",
name = "Should Not Appear",
condition = function()
return false
end,
- order = 1,
},
- shown = { type = "header", name = "Should Appear", order = 2 },
+ { id = "shown", type = "header", name = "Should Appear" },
},
})
@@ -1625,7 +1695,7 @@ describe("LibSettingsBuilder", function()
assert.is_false(headerCreated)
end)
- it("RegisterFromTable condition=true includes entry", function()
+ it("RegisterPage condition=true includes entry", function()
local headerCreated = false
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1637,17 +1707,17 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND2", "CondTest2")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Cond Section 2",
path = "global",
- args = {
- shown = {
+ rows = {
+ {
+ id = "shown",
type = "header",
name = "Conditional Header",
condition = function()
return true
end,
- order = 1,
},
},
})
@@ -1656,7 +1726,7 @@ describe("LibSettingsBuilder", function()
assert.is_true(headerCreated)
end)
- it("RegisterFromTable passes hidden predicates to header initializers", function()
+ it("RegisterPage passes hidden predicates to header initializers", function()
local capturedHeader
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1666,17 +1736,17 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND3", "CondTest3")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Cond Section 3",
path = "global",
- args = {
- shown = {
+ rows = {
+ {
+ id = "shown",
type = "header",
name = "Conditional Header",
hidden = function()
return true
end,
- order = 1,
},
},
})
@@ -1687,15 +1757,15 @@ describe("LibSettingsBuilder", function()
assert.is_false(capturedHeader._shownPredicates[1]())
end)
- it("RegisterFromTable rootCategory=true uses root instead of subcategory", function()
+ it("RegisterPage rootCategory=true uses root instead of subcategory", function()
local SB2 = createSB2("ROOT1", "RootTest")
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Root Section",
rootCategory = true,
path = "global",
- args = {
- mounted = { type = "toggle", path = "hideWhenMounted", name = "Hide", order = 1 },
+ rows = {
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
},
})
@@ -1703,7 +1773,7 @@ describe("LibSettingsBuilder", function()
assert.is_nil(SB2.GetSubcategoryID("Root Section"))
end)
- it("RegisterFromTable canvas type embeds a canvas frame", function()
+ it("RegisterPage canvas rows embed a canvas frame", function()
local SB2 = createSB2("CANVAS1", "CanvasTest")
local canvasFrame = {
@@ -1720,11 +1790,11 @@ describe("LibSettingsBuilder", function()
return origEmbed(canvas, height, spec)
end
- SB2.RegisterFromTable({
+ SB2.RegisterPage({
name = "Canvas Section",
path = "global",
- args = {
- myCanvas = { type = "canvas", canvas = canvasFrame, height = 400, order = 1 },
+ rows = {
+ { id = "myCanvas", type = "canvas", canvas = canvasFrame, height = 400 },
},
})
@@ -2121,10 +2191,7 @@ describe("LibSettingsBuilder", function()
return createScriptableFrame()
end
- TestHelpers.LoadChunk(
- "Libs/LibSettingsBuilder/LibSettingsBuilder.lua",
- "Unable to load LibSettingsBuilder.lua"
- )()
+ TestHelpers.LoadLibSettingsBuilder()
LSB = LibStub("LibSettingsBuilder-1.0")
end)
@@ -2141,14 +2208,14 @@ describe("LibSettingsBuilder", function()
})
end
- it("stores onShow/onHide callbacks when provided in RegisterFromTable", function()
+ it("stores onShow/onHide callbacks when provided in RegisterPage", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onShow = function() end,
onHide = function() end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
assert.is_table(LSB._pageLifecycleCallbacks[cat])
@@ -2166,10 +2233,10 @@ describe("LibSettingsBuilder", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onShow = function() showCount = showCount + 1 end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
navigateTo(cat)
@@ -2180,10 +2247,10 @@ describe("LibSettingsBuilder", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
local hideCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onHide = function() hideCount = hideCount + 1 end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
local other = { _name = "Other" }
@@ -2196,10 +2263,10 @@ describe("LibSettingsBuilder", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
local hideCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onHide = function() hideCount = hideCount + 1 end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
navigateTo(cat)
@@ -2211,10 +2278,10 @@ describe("LibSettingsBuilder", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onShow = function() showCount = showCount + 1 end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
navigateTo(cat)
@@ -2225,7 +2292,7 @@ describe("LibSettingsBuilder", function()
it("does not fire callbacks for categories without lifecycle hooks", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
- sb.RegisterFromTable({ name = "Plain", args = {} })
+ sb.RegisterPage({ name = "Plain", rows = {} })
local untracked = sb._subcategories["Plain"]
-- Should not error
navigateTo(untracked)
@@ -2235,10 +2302,10 @@ describe("LibSettingsBuilder", function()
local sb = makeSB()
sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onShow = function() showCount = showCount + 1 end,
- args = {},
+ rows = {},
})
local cat = sb._subcategories["Page1"]
navigateTo(cat)
@@ -2274,10 +2341,7 @@ describe("LibSettingsBuilder", function()
return deferFrame
end
- TestHelpers.LoadChunk(
- "Libs/LibSettingsBuilder/LibSettingsBuilder.lua",
- "Unable to load LibSettingsBuilder.lua"
- )()
+ TestHelpers.LoadLibSettingsBuilder()
local lsb = LibStub("LibSettingsBuilder-1.0")
local sb = lsb:New({
@@ -2293,10 +2357,10 @@ describe("LibSettingsBuilder", function()
sb.CreateRootCategory("Deferred")
local showCount = 0
- sb.RegisterFromTable({
+ sb.RegisterPage({
name = "Page1",
onShow = function() showCount = showCount + 1 end,
- args = {},
+ rows = {},
})
-- Hooks not yet installed — deferred frame should exist
@@ -2321,8 +2385,8 @@ describe("LibSettingsBuilder", function()
-- SB.Custom integration: template, setting, and InitFrame pipeline
---------------------------------------------------------------------------
describe("Dynamic layout rows", function()
- it("Header accepts action buttons through spec tables", function()
- local init = SB.Header({
+ it("PageActions accepts action buttons through spec tables", function()
+ local init = SB.PageActions({
name = "TestSection",
actions = {
{ text = "Defaults", width = 100 },
@@ -2334,10 +2398,10 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Defaults", init.data.actions[1].text)
end)
- it("Header action tooltips use the current GameTooltip SetText signature", function()
+ it("PageActions tooltips use the current GameTooltip SetText signature", function()
TestHelpers.SetupGameTooltipStub()
- local init = SB.Header({
+ local init = SB.PageActions({
name = "TestSection",
actions = {
{ text = "Add", tooltip = "Create entry" },
@@ -2368,8 +2432,8 @@ describe("LibSettingsBuilder", function()
assert.is_true(_G.GameTooltip._titleWrap)
end)
- it("Header hides the duplicated title when actions are attached to the page title row", function()
- local init = SB.Header({
+ it("PageActions attach buttons to the page title row without rendering a duplicate header", function()
+ local init = SB.PageActions({
name = "TestSection",
category = SB._currentSubcategory,
actions = {
@@ -2396,34 +2460,34 @@ describe("LibSettingsBuilder", function()
assert.are.equal(1, #SB._categoryRefreshables[category])
end)
- it("RegisterFromTable dispatches collection rows through SB.Collection", function()
+ it("RegisterPage dispatches list rows through SB.List", function()
local called
- local originalCollection = SB.Collection
- SB.Collection = function(spec)
+ local originalList = SB.List
+ SB.List = function(spec)
called = spec
- return { _type = "collection" }
+ return { _type = "list" }
end
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = "Collection Page",
- args = {
- items = {
- type = "collection",
+ rows = {
+ {
+ id = "items",
+ type = "list",
height = 200,
- preset = "swatch",
+ variant = "swatch",
items = function()
return {}
end,
- order = 1,
},
},
})
- SB.Collection = originalCollection
+ SB.List = originalList
assert.is_table(called)
assert.are.equal(200, called.height)
- assert.are.equal("swatch", called.preset)
+ assert.are.equal("swatch", called.variant)
end)
it("RefreshCategory reevaluates visible frames and dynamic refreshables", function()
@@ -2455,7 +2519,7 @@ describe("LibSettingsBuilder", function()
assert.is_true(frame._refreshed)
end)
- it("Collection shows cached scroll widgets when a settings row is reused", function()
+ it("List shows cached scroll widgets when a settings row is reused", function()
local originalCreateFrame = _G.CreateFrame
local originalCreateDataProvider = _G.CreateDataProvider
local originalCreateView = _G.CreateScrollBoxListLinearView
@@ -2485,9 +2549,9 @@ describe("LibSettingsBuilder", function()
InitScrollBoxListWithScrollBar = function() end,
}
- local init = SB.Collection({
+ local init = SB.List({
height = 80,
- preset = "swatch",
+ variant = "swatch",
items = function()
return {}
end,
@@ -2516,7 +2580,468 @@ describe("LibSettingsBuilder", function()
_G.ScrollUtil = originalScrollUtil
end)
- it("Collection modeInput trailers reevaluate dynamic fields in place while typing", function()
+ it("swatch list rows open the color callback from the swatch click script", function()
+ local originalCreateFrame = _G.CreateFrame
+ local originalCreateDataProvider = _G.CreateDataProvider
+ local originalCreateView = _G.CreateScrollBoxListLinearView
+ local originalScrollUtil = _G.ScrollUtil
+ local clicked = 0
+ local entered = 0
+
+ _G.CreateFrame = function(frameType, name, parent, template)
+ local frame = originalCreateFrame(frameType, name, parent, template)
+ frame.SetDataProvider = function(self, provider)
+ self._dataProvider = provider
+ end
+ frame.SetColorRGB = function(self, r, g, b)
+ self._color = { r, g, b }
+ end
+ return frame
+ end
+ _G.CreateDataProvider = function()
+ return {
+ Flush = function() end,
+ Insert = function() end,
+ }
+ end
+ _G.CreateScrollBoxListLinearView = function()
+ return {
+ SetElementExtent = function() end,
+ SetElementInitializer = function(self, _, fn)
+ self._initFn = fn
+ end,
+ }
+ end
+ _G.ScrollUtil = {
+ InitScrollBoxListWithScrollBar = function() end,
+ }
+
+ local init = SB.List({
+ height = 80,
+ variant = "swatch",
+ items = function()
+ return {
+ {
+ label = "Spell",
+ icon = 1234,
+ onEnter = function()
+ entered = entered + 1
+ end,
+ color = {
+ value = { r = 0.1, g = 0.2, b = 0.3 },
+ onClick = function()
+ clicked = clicked + 1
+ end,
+ },
+ },
+ }
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+
+ local row = createScriptableFrame()
+ row.CreateTexture = function()
+ local texture = createScriptableFrame()
+ texture.SetTexture = function(self, value)
+ self._texture = value
+ end
+ texture.GetTexture = function(self)
+ return self._texture
+ end
+ return texture
+ end
+ row.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ return fontString
+ end
+ frame._lsbCollectionView._initFn(row, {
+ preset = "swatch",
+ item = init.data.items()[1],
+ })
+
+ local point, relativeTo, relativePoint, x, y = row._swatch:GetPoint(1)
+ assert.is_true(row._mouseEnabled)
+ assert.are.equal("LEFT", point)
+ assert.are.equal(row, relativeTo)
+ assert.are.equal("CENTER", relativePoint)
+ assert.are.equal(-73, x)
+ assert.are.equal(0, y)
+
+ row:GetScript("OnEnter")(row)
+ assert.are.equal(1, entered)
+
+ assert.is_nil(row:GetScript("OnMouseUp"))
+ row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
+
+ assert.are.equal(1, clicked)
+
+ _G.CreateFrame = originalCreateFrame
+ _G.CreateDataProvider = originalCreateDataProvider
+ _G.CreateScrollBoxListLinearView = originalCreateView
+ _G.ScrollUtil = originalScrollUtil
+ end)
+
+ it("swatch list rows rebind the swatch click handler when a recycled row is reused", function()
+ local originalCreateFrame = _G.CreateFrame
+ local originalCreateDataProvider = _G.CreateDataProvider
+ local originalCreateView = _G.CreateScrollBoxListLinearView
+ local originalScrollUtil = _G.ScrollUtil
+ local firstClicks = 0
+ local secondClicks = 0
+
+ _G.CreateFrame = function(frameType, name, parent, template)
+ local frame = originalCreateFrame(frameType, name, parent, template)
+ frame.SetDataProvider = function(self, provider)
+ self._dataProvider = provider
+ end
+ frame.SetColorRGB = function(self, r, g, b)
+ self._color = { r, g, b }
+ end
+ return frame
+ end
+ _G.CreateDataProvider = function()
+ return {
+ Flush = function() end,
+ Insert = function() end,
+ }
+ end
+ _G.CreateScrollBoxListLinearView = function()
+ return {
+ SetElementExtent = function() end,
+ SetElementInitializer = function(self, _, fn)
+ self._initFn = fn
+ end,
+ }
+ end
+ _G.ScrollUtil = {
+ InitScrollBoxListWithScrollBar = function() end,
+ }
+
+ local init = SB.List({
+ height = 80,
+ variant = "swatch",
+ items = function()
+ return {}
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+
+ local row = createScriptableFrame()
+ row.CreateTexture = function()
+ local texture = createScriptableFrame()
+ texture.SetTexture = function(self, value)
+ self._texture = value
+ end
+ texture.GetTexture = function(self)
+ return self._texture
+ end
+ return texture
+ end
+ row.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ return fontString
+ end
+
+ frame._lsbCollectionView._initFn(row, {
+ preset = "swatch",
+ item = {
+ label = "First",
+ color = {
+ value = { r = 0.1, g = 0.2, b = 0.3 },
+ onClick = function()
+ firstClicks = firstClicks + 1
+ end,
+ },
+ },
+ })
+ row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
+
+ frame._lsbCollectionView._initFn(row, {
+ preset = "swatch",
+ item = {
+ label = "Second",
+ color = {
+ value = { r = 0.4, g = 0.5, b = 0.6 },
+ onClick = function()
+ secondClicks = secondClicks + 1
+ end,
+ },
+ },
+ })
+ row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
+
+ assert.are.equal(1, firstClicks)
+ assert.are.equal(1, secondClicks)
+
+ _G.CreateFrame = originalCreateFrame
+ _G.CreateDataProvider = originalCreateDataProvider
+ _G.CreateScrollBoxListLinearView = originalCreateView
+ _G.ScrollUtil = originalScrollUtil
+ end)
+
+ it("editor list rows open the color callback from the swatch click script", function()
+ local originalCreateFrame = _G.CreateFrame
+ local originalCreateDataProvider = _G.CreateDataProvider
+ local originalCreateView = _G.CreateScrollBoxListLinearView
+ local originalScrollUtil = _G.ScrollUtil
+ local clicked = 0
+
+ _G.CreateFrame = function(frameType, name, parent, template)
+ local frame = originalCreateFrame(frameType, name, parent, template)
+ frame.SetDataProvider = function(self, provider)
+ self._dataProvider = provider
+ end
+ frame.SetColorRGB = function(self, r, g, b)
+ self._color = { r, g, b }
+ end
+ return frame
+ end
+ _G.CreateDataProvider = function()
+ return {
+ Flush = function() end,
+ Insert = function() end,
+ }
+ end
+ _G.CreateScrollBoxListLinearView = function()
+ return {
+ SetElementExtent = function() end,
+ SetElementInitializer = function(self, _, fn)
+ self._initFn = fn
+ end,
+ }
+ end
+ _G.ScrollUtil = {
+ InitScrollBoxListWithScrollBar = function() end,
+ }
+
+ local row = createScriptableFrame()
+ row.CreateTexture = function()
+ return createScriptableFrame()
+ end
+ row.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ return fontString
+ end
+
+ local item = {
+ label = "Tick",
+ fields = {},
+ color = {
+ value = { r = 0.1, g = 0.2, b = 0.3 },
+ onClick = function()
+ clicked = clicked + 1
+ end,
+ },
+ remove = {
+ onClick = function() end,
+ },
+ }
+
+ local init = SB.List({
+ height = 80,
+ variant = "editor",
+ items = function()
+ return { item }
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+ frame._lsbCollectionView._initFn(row, {
+ preset = "editor",
+ item = item,
+ })
+
+ row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
+
+ assert.are.equal(1, clicked)
+
+ _G.CreateFrame = originalCreateFrame
+ _G.CreateDataProvider = originalCreateDataProvider
+ _G.CreateScrollBoxListLinearView = originalCreateView
+ _G.ScrollUtil = originalScrollUtil
+ end)
+
+ it("SectionList action rows apply icon button textures, click registration, and spacing", function()
+ local originalCreateFrame = _G.CreateFrame
+
+ local function attachButtonTextureState(frame, key)
+ local texture = createScriptableFrame()
+ texture.ClearAllPoints = function() end
+ texture.SetAllPoints = function(self, owner)
+ self._allPoints = owner
+ end
+ texture.SetAlpha = function(self, alpha)
+ self._alpha = alpha
+ end
+ texture.GetTexture = function(self)
+ return self._texture
+ end
+
+ frame["Set" .. key .. "Texture"] = function(self, value)
+ self["_" .. key:lower() .. "TextureValue"] = value
+ texture._texture = value
+ end
+ frame["Get" .. key .. "Texture"] = function()
+ return texture
+ end
+ end
+
+ _G.CreateFrame = function(...)
+ local frame = originalCreateFrame(...)
+ attachButtonTextureState(frame, "Normal")
+ attachButtonTextureState(frame, "Pushed")
+ attachButtonTextureState(frame, "Disabled")
+ frame.CreateFontString = function()
+ local fontString = createScriptableFrame()
+ fontString.SetFontObject = function() end
+ return fontString
+ end
+ frame.CreateTexture = function()
+ local texture = createScriptableFrame()
+ texture.SetTexture = function(self, value)
+ self._texture = value
+ end
+ texture.GetTexture = function(self)
+ return self._texture
+ end
+ return texture
+ end
+ frame.SetHighlightTexture = function(self, value, blendMode)
+ self._highlightTextureValue = value
+ self._highlightBlendMode = blendMode
+ self._highlightTexture = self._highlightTexture or createScriptableFrame()
+ self._highlightTexture.ClearAllPoints = function() end
+ self._highlightTexture.SetAllPoints = function(texture, owner)
+ texture._allPoints = owner
+ end
+ self._highlightTexture.SetAlpha = function(texture, alpha)
+ texture._alpha = alpha
+ end
+ self._highlightTexture.GetTexture = function(texture)
+ return texture._texture
+ end
+ self._highlightTexture._texture = value
+ end
+ frame.GetHighlightTexture = function(self)
+ return self._highlightTexture
+ end
+ return frame
+ end
+
+ local init = SB.SectionList({
+ height = 120,
+ sections = function()
+ return {
+ {
+ key = "icons",
+ title = "Icons",
+ items = {
+ {
+ label = "Spell",
+ icon = 1234,
+ actions = {
+ up = {
+ text = "^",
+ width = 20,
+ height = 20,
+ enabled = false,
+ buttonTextures = {
+ normal = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
+ pushed = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_down",
+ },
+ },
+ down = {
+ text = "v",
+ width = 20,
+ height = 20,
+ enabled = true,
+ buttonTextures = {
+ normal = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_down_normal",
+ pushed = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_down_down",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ end,
+ })
+ local frame = createScriptableFrame()
+ frame.Text = createScriptableFrame()
+ frame.NewFeature = createScriptableFrame()
+ frame.SetShown = function(self, shown)
+ self._shown = shown
+ end
+
+ init:InitFrame(frame)
+
+ local row = assert(frame._lsbSectionRowPools.icons[1])
+ local upButton = assert(row._buttons.up)
+ local downButton = assert(row._buttons.down)
+
+ assert.are.equal("", upButton:GetText())
+ assert.are.equal(
+ "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
+ upButton._normalTextureValue
+ )
+ assert.are.equal(
+ "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_down",
+ upButton._pushedTextureValue
+ )
+ assert.are.equal(
+ "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
+ upButton._disabledTextureValue
+ )
+ assert.are.equal("Interface\\Buttons\\ButtonHilight-Square", upButton._highlightTextureValue)
+ assert.are.equal("ADD", upButton._highlightBlendMode)
+ assert.are.equal(0.25, upButton:GetHighlightTexture()._alpha)
+ assert.are.equal(0.4, upButton:GetAlpha())
+ assert.is_false(upButton._enabled)
+ assert.are.same({ "LeftButtonDown" }, upButton._registeredClicks)
+
+ local point, relativeTo, relativePoint, x, y = upButton:GetPoint(1)
+ assert.are.equal("RIGHT", point)
+ assert.are.equal(row, relativeTo)
+ assert.are.equal("RIGHT", relativePoint)
+ assert.are.equal(-2, x)
+ assert.are.equal(0, y)
+
+ point, relativeTo, relativePoint, x, y = downButton:GetPoint(1)
+ assert.are.equal("RIGHT", point)
+ assert.are.equal(upButton, relativeTo)
+ assert.are.equal("LEFT", relativePoint)
+ assert.are.equal(-2, x)
+ assert.are.equal(0, y)
+ assert.are.same({ "LeftButtonDown" }, downButton._registeredClicks)
+
+ _G.CreateFrame = originalCreateFrame
+ end)
+
+ it("SectionList modeInput footers reevaluate dynamic fields in place while typing", function()
local originalCreateFrame = _G.CreateFrame
local state = {
kind = "spell",
@@ -2544,7 +3069,7 @@ describe("LibSettingsBuilder", function()
end
local ok, err = pcall(function()
- local init = SB.Collection({
+ local init = SB.SectionList({
height = 120,
sections = function()
return {
@@ -2552,8 +3077,8 @@ describe("LibSettingsBuilder", function()
key = "dynamic",
title = "Dynamic",
items = {},
- trailer = {
- preset = "modeInput",
+ footer = {
+ type = "modeInput",
modeText = function()
return state.kind == "spell" and "Spell" or "Item"
end,
@@ -2698,7 +3223,7 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Global Font", setting:GetValue())
end)
- it("RegisterFromTable dispatches custom type through SB.Custom", function()
+ it("RegisterPage dispatches custom type through SB.Custom", function()
local capturedTemplate
local settings = Settings
local origCEI = settings.CreateElementInitializer
@@ -2707,17 +3232,17 @@ describe("LibSettingsBuilder", function()
return origCEI(template, data)
end)
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = "Test Custom Section",
path = "global",
- args = {
- testHeader = { type = "header", name = "Appearance", order = 1 },
- fontPicker = {
+ rows = {
+ { id = "testHeader", type = "header", name = "Appearance" },
+ {
+ id = "fontPicker",
type = "custom",
path = "font",
name = "Font",
template = "LibLSMSettingsWidgets_FontPickerTemplate",
- order = 2,
},
},
})
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
new file mode 100644
index 00000000..d3864745
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -0,0 +1,102 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers =
+ assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
+
+describe("LibSettingsBuilder Collections", function()
+ local originalGlobals
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "LibStub",
+ "Settings",
+ "CreateFrame",
+ "hooksecurefunc",
+ "SettingsDropdownControlMixin",
+ "SettingsSliderControlMixin",
+ "SettingsListElementMixin",
+ "CreateDataProvider",
+ "CreateScrollBoxListLinearView",
+ "ScrollUtil",
+ })
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ _G.hooksecurefunc = function() end
+ _G.SettingsListElementMixin = {}
+ _G.SettingsDropdownControlMixin = {}
+ _G.SettingsSliderControlMixin = {}
+ _G.CreateFrame = function()
+ return TestHelpers.makeFrame()
+ end
+ _G.CreateDataProvider = function()
+ return {
+ Flush = function(self)
+ self.items = {}
+ end,
+ Insert = function(self, item)
+ self.items = self.items or {}
+ self.items[#self.items + 1] = item
+ end,
+ }
+ end
+ _G.CreateScrollBoxListLinearView = function()
+ return {
+ SetElementExtent = function() end,
+ SetElementInitializer = function(self, _, fn)
+ self._initializer = fn
+ end,
+ }
+ end
+ _G.ScrollUtil = {
+ InitScrollBoxListWithScrollBar = function() end,
+ }
+ TestHelpers.LoadLibSettingsBuilder()
+ end)
+
+ it("creates first-class list and sectionList initializers after the split load", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local SB = lsb:New({
+ pathAdapter = lsb.PathAdapter({
+ getStore = function()
+ return { root = {} }
+ end,
+ getDefaults = function()
+ return { root = {} }
+ end,
+ }),
+ varPrefix = "COLL",
+ onChanged = function() end,
+ })
+ SB.CreateRootCategory("Collections")
+ local category = SB.CreateSubcategory("Rows")
+
+ local listInit = SB.List({
+ category = category,
+ height = 120,
+ items = function()
+ return {}
+ end,
+ variant = "swatch",
+ })
+ local sectionInit = SB.SectionList({
+ category = category,
+ height = 120,
+ sections = function()
+ return {}
+ end,
+ })
+
+ assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, listInit._template)
+ assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, sectionInit._template)
+ assert.is_function(SB.RefreshCategory)
+ end)
+end)
diff --git a/Libs/LibSettingsBuilder/Tests/Controls_spec.lua b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua
new file mode 100644
index 00000000..3b7349c5
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua
@@ -0,0 +1,81 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers =
+ assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
+
+describe("LibSettingsBuilder Controls", function()
+ local originalGlobals
+
+ local function createScriptableFrame()
+ local frame = TestHelpers.makeFrame()
+ frame._text = ""
+ frame._focused = false
+ frame.RegisterForClicks = function(self, ...)
+ self._registeredClicks = { ... }
+ end
+ frame.SetAutoFocus = function() end
+ frame.SetNumeric = function() end
+ frame.SetJustifyH = function() end
+ frame.SetSize = function(self, width, height)
+ self:SetWidth(width)
+ self:SetHeight(height)
+ end
+ frame.SetText = function(self, text)
+ self._text = text
+ end
+ frame.GetText = function(self)
+ return self._text
+ end
+ frame.SetFocus = function(self)
+ self._focused = true
+ end
+ frame.ClearFocus = function(self)
+ self._focused = false
+ end
+ frame.HighlightText = function(self)
+ self._highlighted = true
+ end
+ return frame
+ end
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "LibStub",
+ "Settings",
+ "CreateFrame",
+ "hooksecurefunc",
+ "SettingsDropdownControlMixin",
+ "SettingsSliderControlMixin",
+ "SettingsListElementMixin",
+ "MinimalSliderWithSteppersMixin",
+ })
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ it("installs dropdown and slider hooks when the mixins are available before load", function()
+ local hooks = {}
+
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ _G.hooksecurefunc = function(target, method, fn)
+ hooks[target] = hooks[target] or {}
+ hooks[target][method] = fn
+ end
+ _G.SettingsListElementMixin = {}
+ _G.SettingsDropdownControlMixin = {}
+ _G.SettingsSliderControlMixin = {}
+ _G.CreateFrame = function()
+ return createScriptableFrame()
+ end
+
+ TestHelpers.LoadLibSettingsBuilder()
+
+ assert.is_function(hooks[_G.SettingsDropdownControlMixin].Init)
+ assert.is_function(hooks[_G.SettingsSliderControlMixin].Init)
+ end)
+end)
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
new file mode 100644
index 00000000..1a6f310a
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -0,0 +1,69 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers =
+ assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
+
+describe("LibSettingsBuilder Core", function()
+ local originalGlobals
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "LibStub",
+ "Settings",
+ "CreateFrame",
+ "hooksecurefunc",
+ "SettingsDropdownControlMixin",
+ "SettingsSliderControlMixin",
+ "SettingsListElementMixin",
+ })
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ TestHelpers.LoadLibSettingsBuilder()
+ end)
+
+ it("loads the split library through the shared ordered loader", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ assert.is_table(lsb)
+ assert.is_function(lsb.PathAdapter)
+ assert.is_function(lsb.CreateColorSwatch)
+ assert.is_nil(lsb._loadState.open)
+ end)
+
+ it("PathAdapter resolves nested values and defaults", function()
+ local profile = {
+ root = {
+ enabled = true,
+ },
+ }
+ local defaults = {
+ root = {
+ enabled = false,
+ },
+ }
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local adapter = lsb.PathAdapter({
+ getStore = function()
+ return profile
+ end,
+ getDefaults = function()
+ return defaults
+ end,
+ })
+
+ local binding = adapter:resolve("root.enabled")
+ assert.are.equal(true, binding.get())
+ assert.are.equal(false, binding.default)
+
+ binding.set(false)
+ assert.are.equal(false, profile.root.enabled)
+ end)
+end)
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
new file mode 100644
index 00000000..007c3042
--- /dev/null
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -0,0 +1,306 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local MAJOR = "LibSettingsBuilder-1.0"
+local lib = LibStub(MAJOR, true)
+if not lib or not lib._loadState or not lib._loadState.open then
+ return
+end
+
+local internal = lib._internal
+local copyMixin = internal.copyMixin
+local installPageLifecycleHooks = internal.installPageLifecycleHooks
+
+function lib._installUtility(SB, env)
+ local getCanvasLayoutMetrics = env.getCanvasLayoutMetrics
+
+ function SB.SetCanvasLayoutDefaults(overrides)
+ if not overrides then
+ return lib.CanvasLayoutDefaults
+ end
+
+ return copyMixin(lib.CanvasLayoutDefaults, overrides)
+ end
+
+ function SB.ConfigureCanvasLayout(layout, overrides)
+ assert(layout, "ConfigureCanvasLayout: layout is required")
+ if not overrides then
+ return getCanvasLayoutMetrics(layout)
+ end
+
+ layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides)
+ return layout._metrics
+ end
+
+ function SB.RegisterCategories()
+ if SB._rootCategory then
+ Settings.RegisterAddOnCategory(SB._rootCategory)
+ end
+ end
+
+ function SB.GetRootCategoryID()
+ return SB._rootCategory and SB._rootCategory:GetID()
+ end
+
+ function SB.GetSubcategoryID(name)
+ local category = SB._subcategories[name]
+ return category and category:GetID()
+ end
+
+ function SB.GetRootCategory()
+ return SB._rootCategory
+ end
+
+ function SB.GetSubcategory(name)
+ return SB._subcategories[name]
+ end
+
+ function SB.HasCategory(category)
+ return category ~= nil and SB._layouts[category] ~= nil
+ end
+
+ local DISPATCH = {
+ checkbox = "Checkbox",
+ slider = "Slider",
+ dropdown = "Dropdown",
+ color = "Color",
+ input = "Input",
+ custom = "Custom",
+ }
+
+ function SB.Control(spec)
+ local fn = SB[DISPATCH[spec.type]]
+ assert(fn, "Control: unknown type '" .. tostring(spec.type) .. "'")
+ return fn(spec)
+ end
+
+ function SB.RefreshCategory(categoryOrName)
+ local category = categoryOrName
+ if type(categoryOrName) == "string" then
+ category = SB._subcategories[categoryOrName]
+ or (categoryOrName == SB._rootCategoryName and SB._rootCategory)
+ end
+ if not category then
+ return
+ end
+
+ local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil
+ local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category
+
+ local refreshables = SB._categoryRefreshables[category] or {}
+ for _, initializer in ipairs(refreshables) do
+ if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer)
+ end
+ end
+
+ if not isVisible then
+ return
+ end
+
+ local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ local scrollBox = settingsList and settingsList.ScrollBox
+ if scrollBox and scrollBox.ForEachFrame then
+ scrollBox:ForEachFrame(function(frame)
+ local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ if initializer and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(frame, initializer)
+ end
+ end)
+ end
+ end
+
+ local COMPOSITE_ROW_DISPATCH = {
+ border = function(path, spec)
+ local result = SB.BorderGroup(path, spec)
+ return result.enabledInit, result.enabledSetting
+ end,
+ fontOverride = function(path, spec)
+ local result = SB.FontOverrideGroup(path, spec)
+ return result.enabledInit, result.enabledSetting
+ end,
+ heightOverride = function(path, spec)
+ return SB.HeightOverrideSlider(path, spec)
+ end,
+ }
+
+ local PROXY_ROW_TYPES = {
+ checkbox = true,
+ slider = true,
+ dropdown = true,
+ color = true,
+ input = true,
+ custom = true,
+ }
+
+ local function shouldProcessRow(row)
+ local condition = row and row.condition
+ return condition == nil
+ or (type(condition) == "function" and condition())
+ or (type(condition) ~= "function" and condition)
+ end
+
+ local function resolvePagePath(pagePath, rowPath)
+ if not rowPath or rowPath:find("%.") or pagePath == "" then
+ return rowPath or pagePath
+ end
+ return pagePath .. "." .. rowPath
+ end
+
+ local function copyDeclarativeRowSpec(row)
+ local spec = copyMixin({}, row)
+ spec.id, spec.condition = nil, nil
+ if spec.desc and not spec.tooltip then
+ spec.tooltip = spec.desc
+ end
+ spec.desc = nil
+ return spec
+ end
+
+ local function resolveDeclarativeParent(sourceName, created, rowID, spec)
+ if type(spec.parent) ~= "string" then
+ return
+ end
+
+ local ref = created[spec.parent]
+ assert(
+ ref,
+ sourceName .. ": parent '" .. spec.parent .. "' not found for row '" .. tostring(rowID or spec.name or spec.type) .. "'"
+ )
+ spec.parent = ref.initializer
+ local parentCheck = spec.parentCheck
+ if parentCheck == "checked" or parentCheck == "notChecked" then
+ local setting = ref.setting
+ assert(setting, sourceName .. ": parentCheck='" .. parentCheck .. "' requires a parent setting")
+ spec.parentCheck = parentCheck == "checked" and function()
+ return setting:GetValue()
+ end or function()
+ return not setting:GetValue()
+ end
+ end
+ end
+
+ local function registerLabeledList(page, spec, builder)
+ if spec.label then
+ local labelInit = SB.Subheader({ name = spec.label, disabled = spec.disabled, hidden = spec.hidden })
+ spec.parent = spec.parent or labelInit
+ end
+ local results = builder(resolvePagePath(page.path or "", spec.path), spec.defs or {}, spec)
+ return results[1] and results[1].initializer, results[1] and results[1].setting
+ end
+
+ local DECLARATIVE_ROW_BUILDERS = {
+ button = function(spec)
+ return SB.Button(spec)
+ end,
+ canvas = function(spec)
+ return SB.EmbedCanvas(spec.canvas, spec.height, spec)
+ end,
+ checkboxList = function(spec, page)
+ return registerLabeledList(page, spec, SB.CheckboxList)
+ end,
+ colorList = function(spec, page)
+ return registerLabeledList(page, spec, SB.ColorPickerList)
+ end,
+ header = function(spec)
+ return SB.Header(spec)
+ end,
+ info = function(spec)
+ return SB.InfoRow(spec)
+ end,
+ list = function(spec)
+ return SB.List(spec)
+ end,
+ pageActions = function(spec)
+ return SB.PageActions(spec)
+ end,
+ sectionList = function(spec)
+ return SB.SectionList(spec)
+ end,
+ subheader = function(spec)
+ return SB.Subheader(spec)
+ end,
+ }
+
+ local function registerDeclarativeRow(sourceName, page, row, created)
+ local rowType = row.type
+ assert(rowType, sourceName .. ": each row requires a type")
+
+ local spec = copyDeclarativeRowSpec(row)
+ if page.disabled and spec.disabled == nil then
+ spec.disabled = page.disabled
+ end
+ if page.hidden and spec.hidden == nil then
+ spec.hidden = page.hidden
+ end
+
+ resolveDeclarativeParent(sourceName, created, row.id, spec)
+
+ local init, setting
+ local builder = DECLARATIVE_ROW_BUILDERS[rowType]
+ if builder then
+ init, setting = builder(spec, page)
+ elseif COMPOSITE_ROW_DISPATCH[rowType] then
+ local path = resolvePagePath(page.path or "", spec.path)
+ init, setting = COMPOSITE_ROW_DISPATCH[rowType](path, spec)
+ elseif PROXY_ROW_TYPES[rowType] then
+ if not spec.get then
+ spec.path = resolvePagePath(page.path or "", spec.path)
+ elseif not spec.key then
+ spec.key = row.id
+ end
+ if spec.get and not spec.key then
+ error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id")
+ end
+ spec.type = rowType
+ init, setting = SB.Control(spec)
+ else
+ error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
+ end
+
+ if row.id then
+ created[row.id] = { initializer = init, setting = setting }
+ end
+ end
+
+ function SB.RegisterPage(page)
+ assert(page.name, "RegisterPage: page.name is required")
+
+ if page.rootCategory then
+ SB._currentSubcategory = SB._rootCategory
+ else
+ SB.CreateSubcategory(page.name, page.parentCategory)
+ end
+
+ if page.onShow or page.onHide then
+ lib._pageLifecycleCallbacks[SB._currentSubcategory] = {
+ onShow = page.onShow,
+ onHide = page.onHide,
+ }
+ installPageLifecycleHooks()
+ end
+
+ local created = {}
+ for _, row in ipairs(page.rows or {}) do
+ if shouldProcessRow(row) then
+ registerDeclarativeRow("RegisterPage", page, row, created)
+ end
+ end
+
+ return SB._currentSubcategory
+ end
+
+ function SB.RegisterSection(nsTable, key, section)
+ nsTable.OptionsSections = nsTable.OptionsSections or {}
+ nsTable.OptionsSections[key] = section
+ return section
+ end
+
+ return SB
+end
+
+lib._loadState.open = nil
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index df3b24db..09c9c942 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -145,9 +145,9 @@ Notes:
Dispatches to the correct control factory using `spec.type`.
-### `SB.Collection(spec)`
+### `SB.List(spec)`
-Creates a first-class dynamic collection row backed by the normal settings list.
+Creates a first-class dynamic flat list row backed by the normal settings list.
Required fields:
@@ -155,19 +155,23 @@ Required fields:
Flat-list fields:
-- `preset = "swatch"` or `preset = "editor"`
+- `variant = "swatch"` or `variant = "editor"`
- `items(frame)` → item list
+### `SB.SectionList(spec)`
+
+Creates a first-class grouped dynamic list row backed by the normal settings list.
+
Sectioned-list fields:
- `sections(frame)` → section list
-Supported collection row presets:
+Supported list variants:
- `swatch` — label/icon plus color swatch rows
- `editor` — label plus one or more slider fields, optional swatch, and remove button
- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`)
-- section trailers support `preset = "modeInput"` for toggle + input + preview + submit rows
+- section trailers support `type = "modeInput"` for toggle + input + preview + submit rows
Mode-input trailer display fields may be static values or functions that are re-evaluated during in-place row refreshes.
## Composite builders
@@ -185,31 +189,34 @@ Supported collection row presets:
- `SB.InfoRow(spec)`
- `SB.EmbedCanvas(canvas, height[, spec])`
- `SB.Button(spec)`
+- `SB.PageActions(spec)`
- `SB.RegisterSection(nsTable, key, section)`
`SB.Button` supports `confirm = true` or a custom confirm string. Confirm dialogs are registered per button to avoid cross-button collisions.
-`SB.Header` also accepts spec tables with `actions = { ... }` for right-aligned action buttons. When the first action header matches the current category title, LibSettingsBuilder treats it as a page action bar and suppresses the duplicate in-list title text.
+`SB.PageActions` renders right-aligned category-header action buttons.
`SB.InfoRow` accepts function-backed `value` for dynamic text.
`SB.RefreshCategory(...)` re-evaluates registered dynamic rows for a visible category.
-## Table-driven registration
+## Page registration
-### `SB.RegisterFromTable(tbl)`
+### `SB.RegisterPage(page)`
-Supported standard types:
+Supported canonical row types:
-- `checkbox` / `toggle`
-- `slider` / `range`
-- `dropdown` / `select`
+- `checkbox`
+- `slider`
+- `dropdown`
- `input`
- `color`
- `custom`
-- `button` / `execute`
+- `button`
- `header`
-- `subheader` / `description`
+- `subheader`
- `info`
- `canvas`
-- `collection`
+- `pageActions`
+- `list`
+- `sectionList`
Supported composite types:
@@ -217,22 +224,18 @@ Supported composite types:
- `fontOverride`
- `heightOverride`
- `colorList`
-- `toggleList`
+- `checkboxList`
## Implementation model
The library has three main families of row builders:
- **proxy controls** — persisted values backed by `Settings.RegisterProxySetting` (`checkbox`, `slider`, `dropdown`, `color`, `input`, `custom`),
-- **layout rows** — structural/display rows without stored values (`header`, `subheader`, `info`, `button`, `canvas`),
-- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `toggleList`).
+- **layout rows** — structural/display rows without stored values (`header`, `subheader`, `info`, `button`, `canvas`, `pageActions`),
+- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `checkboxList`).
`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template.
-`collection` is the preferred way to build dynamic editors without dropping into a second "canvas" authoring API. `canvas` / `EmbedCanvas` remain available as escape hatches for truly bespoke frames.
-
-## Legacy canvas helpers
-
-Canvas-layout helpers still exist for backwards compatibility, but new pages should prefer `RegisterFromTable(...)` plus `collection`, dynamic `info` rows, handler-mode `dropdown`, and `header.actions`.
+`canvas` rows stay on the current lifecycle path. Keep using `SB.EmbedCanvas(...)` for bespoke frames when a built-in row is not enough.
#### `SB.SetCanvasLayoutDefaults(overrides)`
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index 6dada88e..74e98fa8 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -4,7 +4,7 @@
- [README](../README.md) — overview and quick links
- [Quick Start](QUICK_START.md) — common setup patterns
-- [API Reference](API_REFERENCE.md) — builder, controls, composites, canvas helpers
+- [API Reference](API_REFERENCE.md) — builder, controls, composites, page registration, canvas helpers
- [Migration Guide](MIGRATION_GUIDE.md) — moving from AceConfig/AceGUI
- [Troubleshooting](TROUBLESHOOTING.md) — common issues and fixes
@@ -21,14 +21,15 @@ Include the library in your addon's TOC or load-on-demand manifest.
```toc
Libs\LibStub\LibStub.lua
-Libs\LibSettingsBuilder\LibSettingsBuilder.lua
+Libs\LibSettingsBuilder\embed.xml
```
Recommended pattern for addon authors:
1. Ship the library inside your addon's `Libs/` folder.
2. Load `LibStub` before `LibSettingsBuilder`.
-3. Treat the library as embedded, not as a standalone dependency players must install separately.
+3. Load `Libs\LibSettingsBuilder\embed.xml` so the library's internal source files keep their required order.
+4. Treat the library as embedded, not as a standalone dependency players must install separately.
## Versioning notes
@@ -57,7 +58,7 @@ Those hooks are part of the library's behavior and should be considered when deb
Most library features are available with no extra XML:
- proxy controls like `checkbox`, `slider`, `dropdown`, `color`, and `input`,
-- layout rows like `header`, `subheader`, `info`, `button`, and `canvas`,
+- layout rows like `header`, `subheader`, `info`, `button`, `pageActions`, and `canvas`,
- composite builders like `border`, `fontOverride`, and `heightOverride`.
`input` is a built-in row type implemented entirely in Lua on top of `SettingsListElementTemplate` plus a runtime-created `InputBoxTemplate` edit box.
@@ -68,9 +69,9 @@ Only `SB.Custom(...)` requires you to supply your own template. In that case:
2. load that XML from your TOC before registering categories, and
3. pass the template name through `spec.template`.
-## Legacy canvas layout compatibility
+## Canvas layout compatibility
-Canvas layout spacing defaults are still available for older `CreateCanvasLayout(...)` pages, but new work should prefer `RegisterFromTable(...)` plus `collection` / dynamic rows.
+Canvas layout spacing defaults are still available for older `CreateCanvasLayout(...)` pages. New `canvas` rows stay on the current lifecycle path, so canvas content continues to reuse the existing frame handling without special-case rewrites.
- per-library via `SB.SetCanvasLayoutDefaults(overrides)`
- per-layout via `SB.ConfigureCanvasLayout(layout, overrides)`
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index 3454b724..059520a1 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -23,7 +23,7 @@
| AceConfig stack | LibSettingsBuilder |
|---|---|
-| `RegisterOptionsTable` | `SB.RegisterFromTable` |
+| `RegisterOptionsTable` | `SB.RegisterPage` |
| `AddToBlizOptions` | `SB.RegisterCategories()` |
| one `get`/`set` per field | one `path` per field in path mode |
| `type = "input"` | `type = "input"` or `SB.Input(...)` |
@@ -48,23 +48,29 @@ local SB = LSB:New({
})
```
-## Useful aliases
+## Canonical row types
-`RegisterFromTable` accepts several AceConfig-style aliases:
+`RegisterPage` uses canonical row types only:
-- `toggle` → `checkbox`
-- `range` → `slider`
-- `select` → `dropdown`
-- `input` → `input`
-- `execute` → `button`
-- `description` → `subheader`
-- `desc` → `tooltip`
+- `checkbox`
+- `slider`
+- `dropdown`
+- `input`
+- `color`
+- `button`
+- `header`
+- `subheader`
+- `info`
+- `canvas`
+- `pageActions`
+- `list`
+- `sectionList`
## Features you gain
- native Blizzard Settings integration,
- composite builders for common UI groups,
-- first-class dynamic collection rows for complex editors,
+- first-class dynamic list rows for complex editors,
- built-in text input rows with optional debounced previews,
- explicit category refresh hooks for async/transient state,
- clickable slider value editing,
@@ -77,7 +83,7 @@ local SB = LSB:New({
If you only need text or numeric entry, use the built-in `input` type first. Reach for `SB.Custom(...)` only when you need a genuinely different widget.
-If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "collection"` before reaching for `SB.Custom(...)` or `SB.EmbedCanvas(...)`.
+If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "list"` or `type = "sectionList"` before reaching for `SB.Custom(...)` or `SB.EmbedCanvas(...)`.
## Migrating AceConfig input fields
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index d85b39d4..442bba6c 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -10,11 +10,11 @@
## Choose a setup style
-- Use **table-driven registration** if you want the shortest path to a normal settings page.
+- Use **page registration** if you want the shortest path to a normal settings page.
- Use the **imperative API** if you want precise control over layout and call order.
- Use **handler mode** if your settings are not stored in a dot-path table.
- Use `input` rows when you need text or numeric entry without building a custom template.
-- Use `collection` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API.
+- Use `list` or `sectionList` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API.
## Table-driven setup
@@ -38,34 +38,31 @@ local SB = LSB:New({
SB.CreateRootCategory("My Addon")
-SB.RegisterFromTable({
+SB.RegisterPage({
name = "General",
path = "general",
- args = {
- enabled = {
- type = "toggle",
+ rows = {
+ {
+ type = "checkbox",
path = "enabled",
name = "Enable",
desc = "Enable or disable the addon.",
- order = 1,
},
- opacity = {
- type = "range",
+ {
+ type = "slider",
path = "opacity",
name = "Opacity",
min = 0,
max = 100,
step = 1,
- order = 2,
},
- spellId = {
+ {
type = "input",
path = "spellIdText",
name = "Spell ID",
numeric = true,
maxLetters = 10,
debounce = 1,
- order = 3,
resolveText = function(value)
local id = tonumber(value)
return id and C_Spell.GetSpellName(id) or nil
@@ -111,7 +108,7 @@ SB.Input({
SB.RegisterCategories()
```
-`RegisterFromTable(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `toggle`, `range`, `input`, `header`, `description`, `info`, `button`, `collection`, and `canvas` entries on one page.
+`RegisterPage(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `checkbox`, `slider`, `input`, `header`, `subheader`, `info`, `button`, `pageActions`, `list`, `sectionList`, and `canvas` entries on one page.
## Handler mode
@@ -161,4 +158,4 @@ SB.RegisterCategories()
- Use composites for repeated patterns like borders, font overrides, and positioning.
- Prefer table-driven registration for large standard settings pages.
- Use `SB.RefreshCategory(...)` for async or transient state that needs the visible page to redraw.
-- Reach for `SB.Custom(...)` or `SB.EmbedCanvas(...)` only when built-ins like `input` and `collection` stop fitting.
+- Reach for `SB.Custom(...)` or `SB.EmbedCanvas(...)` only when built-ins like `input`, `list`, and `sectionList` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index 86f4bfc4..4fab3e66 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -92,7 +92,7 @@ If debugging slider behavior:
## Legacy canvas pages look slightly off after a WoW patch
-Canvas layout spacing is configurable for older `CreateCanvasLayout(...)` pages. New pages should prefer the standard settings DSL with `collection` rows instead of tuning canvas metrics by default.
+Canvas layout spacing is configurable for older `CreateCanvasLayout(...)` pages. New pages should prefer the standard settings DSL with `list` or `sectionList` rows instead of tuning canvas metrics by default.
Use:
diff --git a/Libs/LibSettingsBuilder/embed.xml b/Libs/LibSettingsBuilder/embed.xml
new file mode 100644
index 00000000..665eda0d
--- /dev/null
+++ b/Libs/LibSettingsBuilder/embed.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Media/delete_down.tga b/Media/delete_down.tga
new file mode 100644
index 0000000000000000000000000000000000000000..dc8abdb5277edafa6c6ec1012549b41a61cc4b7e
GIT binary patch
literal 1068
zcmb7@T@Jxe5Jqo2dF;ENwpy*WTEdeB5KV-HYV5==?8`S5RewsigehqE9o}eLO0`CbCy8B;U79OIZ~olif%d#M6FRCww4t`cx;*Y!fNX}I2B9?al<{9deE
zestw4(P7|@w
v``9zAwI9W)RDr&I2GF|~o*($Qcoy!h7wdLkFTavaw#q&~g6(^@NP6ZUzega<
literal 0
HcmV?d00001
diff --git a/Media/hide_down.tga b/Media/hide_down.tga
new file mode 100644
index 0000000000000000000000000000000000000000..ed4550887069b731bb4312ee88839289e802896f
GIT binary patch
literal 1068
zcmb7@O%8%U3`QreT(b8M1VKR1=*k0NBr%#O5^v%yyq6d80A8S`>W7me(V67YPU)k}
zVw7qrEK6C+Zev%RFJuBdj_fVhr4j7tZpZe?1fGZg<-F?zM%4UaNL`KV?M^!SJIq(2-_$pq=U
z;jB78=S|>jH9wk@`vyJVB6_6n#S1b4zJ3R|_Aj_U?DN81_&2_+&$HMcvob&AyKQn>
I<(n*34`WpQ&j0`b
literal 0
HcmV?d00001
diff --git a/Media/hide_normal.tga b/Media/hide_normal.tga
new file mode 100644
index 0000000000000000000000000000000000000000..f5f7d0c0355c6cf55c763b38b644a42101ac6df2
GIT binary patch
literal 1068
zcmZQzU}As)0R{mE1r8W5scZ3nQZP&(hz99%kIEqlgY+QlRnjv2PYMRv2Xilot)gv=
zfu)qRwjg6
;*RN%44YwQFE?jJM_iLNj{)b^)dO+eZHR$?v&Fue^g3F=i-;pXY%s>c8Tcg+*z
literal 0
HcmV?d00001
diff --git a/Media/move_down_down.tga b/Media/move_down_down.tga
new file mode 100644
index 0000000000000000000000000000000000000000..df2e60ca628936bc6fa780611255f61709a45316
GIT binary patch
literal 1068
zcmb7@O%8%U3`QreT(TFHKS4my=*k0VBr%#O5^v%yyq6d80A8S`F^yl!EMSsHXXfk6
zu&FTvBg-+4@rH7$A5ZFocwE`r9-3aTgYL+S$;RvbvEK80^1r-y{iFrEg3eohkdh0C
zhZS?}kHd@vbo5?&v_FY*VsyXl%zfIQ&K88&Jj}K4#RVZYA9L;dNlA#!$6Witd`XDS
z$6Whix+28pW8U(!HHAIUew1&h58~qt@LgAAe%R+Fv(UBvtpB&Z+;&ZMt_TDoo(Q51@n*mp@pmZr
z^Pcnnjc%SXC4*%d%T&uaU2b6-|2=V
z$?}oWzT2OXB+Exe``&O)k}Mw??RUltlBD^vuYG?SQ4zEsgfSHXzPtncE-$!0?DN81
Y$XmbG@44R|qGPg4Hf#5^OjeO+K2aAkC;$Ke
literal 0
HcmV?d00001
diff --git a/Media/move_up_down.tga b/Media/move_up_down.tga
new file mode 100644
index 0000000000000000000000000000000000000000..eb5cd3b992e28d4f9cf2464b0be230a7b5764af7
GIT binary patch
literal 1068
zcmb7@OAdlS42CDJT(TDx0YO30=*k0VBr%%!Al}4VcrP#F0lYv>HI2Vz7R)3coj$${
zn;O$Hay;Xipe@TRFiFx}6{)7x+E-U%vZ;^pD5$MH-x``Qey+K)ak{
zU;EuCBgE!oU;Di{C&cDsU;F*xgb%QTJ+owxGzu$F3r*wK`hCyG%epF0t>yArqI7P<*H;sUyXCl|T+
zyd;neoXotLdy*mWLg)pKK^TNlpVj{3)4D(pGrV&5-U&F~9Y?7t!*O&_n8EjWU#w52
z*+0I%kEY;V%&C7GUz$f<)bN>6|LlBjo+h6e^(X1VJWW0`>POki2=3YB^M9&8o#h7V
zJl#W1{rKwEh~|CGjQYv-+C1Lj+Mla`vD}zK53`=BpWYN!2i&@S^|QRRF3|TqfW5Y`
ff8b-V7uwFh*57mc^isT5&(-5Y{B~E}7fJX5OxQB6
literal 0
HcmV?d00001
diff --git a/Media/preview.png b/Media/preview.png
new file mode 100644
index 0000000000000000000000000000000000000000..16972160eecabfa3416bdeb8d4a1c0ffc236e54f
GIT binary patch
literal 1701
zcmV;W23q-vP)dH!Jk_fX&P
zm3wS5x&r6_+rIgFy4$wAGzw?WoZFT7nU}Db6Qi~d4_tz}lV2)3m*t10o#YP>Tw>bk
z<_h`0|FH(kOQQe)fi0=Y=n7;WOIPNj{9}-fn_WP$;Nzj|Lzi7=HIWV}2eN!*lQQu^
zi{J0hl#emjL^@z~ZJla|CmSy{Dtzo;6X~GlCnLL>T1+Z@8W&)@phP)h-i5_tG0(^E
z==#uQ&v8}x`82Jrt%t^^`6I`m!>_LW0y46zVRdaC($ik1e$Zwzx}wF$IBCM-$;htO
zieF~jcn(;Snt{e|bWv?aN3Ot$Puqn-i1=aHvHG!MM~#kL0Ra6OBJFUhXP>6D!>RD$
z+plWzQU2M+zyC?@QEfh}0Flod8>fJ_s%~Pen;XdZjV`FHYUA4Q_+i=U_9K(0w1yz~
zyd3G|hU8aPwZVgpO;}zUh03Znm>BDJiwBRH@KMME*cuNRx84loY*k&wiO*r<;Uns`
z)>FCf9+8XO#}CVn)sKvyjF;R`O-Y82-g`}%e{=i?p&b^N@(f3eJ2fTQ!@_g%U}Mug
zrYc%qNgi34ymKI&fdBwrN`8?E;2q-$`L`ynGHozX<&~}4e3Ik;5`@8*S$`}qjjG=z
zQSyT`4{pQCkEq>v4a5(`Y>iS@(E?L9e`MNipP)CC`8+PE%}0M~@yjY&+>=;V(c(Ey
z`t~1_<@?3@BIw31+itW55(i?wMky(8hM8MG1#GZ*UU)u9i(gXS41fLo*ywJB#|MCb
z4-~ee)EBvYK7iSH$owZHBtS`dv)f)$-V6x|2~=CZc6|my6_+)^?9?@PACr@mn5g9g
z`y?eMde}*ciCXcp_@s}=Po8*eOnk}kO?)7~xU31Ha|2YH;SKqzT(~`7+}c*-2f(7H9jYHVe0V
zA+Pw9FbEOf#75MY6+3F-b}s;^&k#F(3C=4%Ip3V(jm~m8Q+-@n=lUVHs3EY`6&b(dI36DPw{ZdG6rKbC
zfV+2s@6F;d9qm*Bk@1u9`uBDCw4C^b#TCX3hb-KN^#vmr?7TLbl<&e+ZA5*S<_DQJ
zc!IJE>b3c#5B~`dgD=yUH%1Yk?3-Os4@>id?l!Fa?CAo^%8$Ukg(0S0R(?dA&*Rc!
zQRS1d`{N{iXXQuWgLl;abC(tq7L&gqP1itsx_|%xGV|)-{^D@JCNr!h|ZNj7R
zNv1!3Q(q#!sSh&$ad8JdtjxSRh>wp8*wLi)97xATw(rNeb?|d^*t2gUl8QtCh`+@;x6158*u?{;E<_~P7Z`{n@nfU
z&)3_R8$u`W>4t7dZdh$TJ}pM%;NYHDyC{KCwwL4+oMeYxaD4p)X6^
z?)lqnVvuLf)!6g=-Dql%?z5(IhT8Lk@yt}*RoA)ale{t&IMm*`bvObNLqDYe
literal 0
HcmV?d00001
diff --git a/Media/show_normal.tga b/Media/show_normal.tga
new file mode 100644
index 0000000000000000000000000000000000000000..90218c04d845d51a3843ee710e41c762c0a3408c
GIT binary patch
literal 1068
zcmb7@Pin$Y5XK)}xpduC35q2lsUg<-FNjDgfl{HhB3*XvNxX(v@Dg6c19*WcpYrKA
z1~Cu5%*@ODX67Y4=N=qa)m7b7h0)(?EoDRw7Tn}_SxR6u>-n*i5&Cgn__ZLp(xc~Z
zte9u{diW|7nC)&aR=Squ8!vAHS*9pP9m_w*U4e8y=b+^~Y<*L*z4F3zes?X!mE
zlXP<6Vd?8!yT|XR?*R5F^n_1qecm7NSUk(U_DG$dv#+0Qo`2`l&v5ace`JyS2T8h5
AL;wH)
literal 0
HcmV?d00001
diff --git a/Media/swap_down.tga b/Media/swap_down.tga
new file mode 100644
index 0000000000000000000000000000000000000000..00474542584c466f9696fb98631ddbccf40afc33
GIT binary patch
literal 1068
zcmb7@OA5j;5Qc**m+r0Fm(SWNy7B-O3!>nocoT2oy}XDA@B$-$bugVm+62B#CNm$&
zW|V3vULDm@Zkw~q{XsUchX`-8%dLQ;yIprgHsC$}FV2oPF7bT5OS!C9e1A&L54eS|
z-Eobh^n1aqaM(3P^h&=UM#Pw0)X*pW!EjDG=riTgcSkXSy0^Jc`rbGpQ0H@9`u=1=
zCfMU1=?Bv#nSig?EBDZ`dA8IwyhJDx9+1bT?@W`|QE;Mi{7@kEo&J6GMu9P4qvcrR4^FK+jT
zz}NlEwO@3b0^q= 0 and value or nil
end,
- order = 24,
},
- verticalSpacing = {
- type = "range",
+ {
+ id = "verticalSpacing",
+ type = "slider",
path = "verticalSpacing",
name = L["AURA_VERTICAL_SPACING"],
desc = L["AURA_VERTICAL_SPACING_DESC"],
@@ -459,9 +476,8 @@ function BuffBarsOptions.RegisterSettings(SB)
step = 1,
disabled = isDisabled,
getTransform = defaultZero,
- order = 25,
},
- fontOverride = { type = "fontOverride", disabled = isDisabled, order = 26 },
+ { id = "fontOverride", type = "fontOverride", disabled = isDisabled },
},
})
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 1f9d18da..dc5c1151 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -9,46 +9,31 @@ local L = ns.L
local BUILTIN_STACKS = C.BUILTIN_STACKS
local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
local RACIAL_ABILITIES = C.RACIAL_ABILITIES
-
-local TOOLTIP_ITEM_ICON_SIZE = 14
-local TOOLTIP_QUALITY_ICON_SIZE = 14
+local BUILTIN_EQUIP_SLOTS = {}
local VIEWER_COLLECTION_HEIGHT = 448
+local ACTION_ICON_BUTTON_SIZE = 20
local DEFAULT_SPECIAL_VIEWER = "utility"
-local DRAFT_PENDING_TEXT = "..."
local VIEWER_ORDER = { "utility", "main" }
-local VIEWER_LABELS = {
- utility = "UTILITY_VIEWER_ICONS",
- main = "MAIN_VIEWER_ICONS",
-}
-local RACIAL_ALIASES = {
- undead = "Scourge",
- earthen = "EarthenDwarf",
-}
-
-local RACIAL_ABILITIES_BY_NORMALIZED_KEY = {}
-for raceKey, racialEntry in pairs(RACIAL_ABILITIES) do
- if type(raceKey) == "string" then
- local normalizedKey = raceKey:gsub("[^%a%d]", ""):lower()
- RACIAL_ABILITIES_BY_NORMALIZED_KEY[normalizedKey] = racialEntry
- end
-end
+local VIEWER_LABELS = { utility = "UTILITY_VIEWER_ICONS", main = "MAIN_VIEWER_ICONS" }
-local function getViewerShortLabel(viewerKey)
- return viewerKey == "utility" and L["UTILITY_VIEWER_SHORT"] or L["MAIN_VIEWER_SHORT"]
+local ACTION_BUTTON_TEXTURE_BASE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\"
+local function makeTexturePair(name)
+ return { normal = ACTION_BUTTON_TEXTURE_BASE .. name .. "_normal", pushed = ACTION_BUTTON_TEXTURE_BASE .. name .. "_down" }
end
+local ACTION_BUTTON_TEXTURES = {
+ delete = makeTexturePair("delete"), hide = makeTexturePair("hide"),
+ moveDown = makeTexturePair("move_down"), moveUp = makeTexturePair("move_up"),
+ show = makeTexturePair("show"), swap = makeTexturePair("swap"),
+}
-local function getBuiltinOrderIndex(stackKey)
- for index, builtinKey in ipairs(BUILTIN_STACK_ORDER) do
- if builtinKey == stackKey then
- return index
- end
- end
-
- return nil
+local BUILTIN_STACK_SET = {}
+for _, key in ipairs(BUILTIN_STACK_ORDER) do BUILTIN_STACK_SET[key] = true end
+for _, stack in pairs(BUILTIN_STACKS) do
+ if stack.kind == "equipSlot" and stack.slotId then BUILTIN_EQUIP_SLOTS[stack.slotId] = true end
end
local function isDisabledBuiltinEntry(entry)
- return entry and entry.stackKey and entry.disabled and getBuiltinOrderIndex(entry.stackKey) ~= nil
+ return entry and entry.stackKey and entry.disabled and BUILTIN_STACK_SET[entry.stackKey] == true
end
local ExtraIconsOptions = {}
@@ -56,513 +41,350 @@ ns.ExtraIconsOptions = ExtraIconsOptions
ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
--------------------------------------------------------------------------------
--- Data Helpers
+-- Entry Data Helpers
--------------------------------------------------------------------------------
---- Check if a stackKey is present in any viewer's entries.
-function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if entry.stackKey == stackKey then
- return true
- end
- end
- end
- return false
+local function getEntrySpellId(entry)
+ if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then return nil end
+ local first = entry.ids[1]
+ return type(first) == "table" and first.spellId or first
end
-local function lookupRacialEntryByRaceKey(raceKey)
- if type(raceKey) ~= "string" or raceKey == "" then
- return nil
- end
-
- local direct = RACIAL_ABILITIES[raceKey]
- if direct then
- return direct
- end
-
- local normalizedKey = raceKey:gsub("[^%a%d]", ""):lower()
- local aliasKey = RACIAL_ALIASES[normalizedKey]
- if aliasKey and RACIAL_ABILITIES[aliasKey] then
- return RACIAL_ABILITIES[aliasKey]
- end
-
- return RACIAL_ABILITIES_BY_NORMALIZED_KEY[normalizedKey]
+local function getItemIdFromEntry(entry)
+ return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
end
-local function lookupKnownRacialEntry()
- if type(IsPlayerSpell) ~= "function" then
- return nil
- end
-
+local function getCurrentRacialEntry()
+ local _, raceFile = UnitRace("player")
+ local entry = RACIAL_ABILITIES[raceFile]
+ if entry then return entry end
for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry and racialEntry.spellId and IsPlayerSpell(racialEntry.spellId) then
- return racialEntry
- end
+ if racialEntry.spellId and C_SpellBook.IsSpellKnown(racialEntry.spellId) then return racialEntry end
end
-
return nil
end
-local function getCurrentRacialEntry()
- local raceName, raceFile = UnitRace("player")
- return lookupRacialEntryByRaceKey(raceFile)
- or lookupRacialEntryByRaceKey(raceName)
- or lookupKnownRacialEntry()
-end
-
local function getCurrentRacialSpellId()
local racial = getCurrentRacialEntry()
return racial and racial.spellId or nil
end
-local function getEntrySpellId(entry)
- if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
- return nil
- end
-
- local first = entry.ids[1]
- return type(first) == "table" and first.spellId or first
-end
-
-local function markPendingItemLoad(itemId)
- if itemId then
- ExtraIconsOptions._pendingItemLoads[itemId] = true
- end
-end
-
---- Check if a racial spellId is present in any viewer's entries.
-function ExtraIconsOptions._isRacialPresent(viewers, spellId)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if getEntrySpellId(entry) == spellId then
- return true
- end
- end
- end
- return false
-end
-
-function ExtraIconsOptions._isCurrentRacialEntry(entry)
- local racialSpellId = getCurrentRacialSpellId()
- return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
-end
-
-local function getItemIdFromEntry(entry)
- return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
-end
-
local function getItemDisplayName(itemId)
- if not itemId then
- return nil
- end
-
+ if not itemId then return nil end
local name = C_Item.GetItemNameByID(itemId)
if name then
ExtraIconsOptions._pendingItemLoads[itemId] = nil
return name
end
-
if C_Item.DoesItemExistByID(itemId) then
- markPendingItemLoad(itemId)
+ ExtraIconsOptions._pendingItemLoads[itemId] = true
C_Item.RequestLoadItemDataByID(itemId)
return L["EXTRA_ICONS_ITEM_LOADING"]
end
-
return "Item " .. tostring(itemId)
end
-local function getEquippedItemDisplayName(slotId)
- local itemId = GetInventoryItemID("player", slotId)
- if not itemId then
- return nil
- end
-
- return getItemDisplayName(itemId)
-end
-
-local function getTextureMarkup(texture, size)
- if not texture or type(CreateTextureMarkup) ~= "function" then
- return nil
+function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if entry.stackKey == stackKey then return true end
+ end
end
-
- return CreateTextureMarkup(texture, 64, 64, size, size, 0, 1, 0, 1)
+ return false
end
-local function getQualityMarkup(quality)
- if not quality then
- return nil
- end
-
- if type(CreateAtlasMarkup) == "function" then
- return CreateAtlasMarkup(
- "Professions-Icon-Quality-Tier" .. tostring(quality) .. "-Small",
- TOOLTIP_QUALITY_ICON_SIZE,
- TOOLTIP_QUALITY_ICON_SIZE
- )
- end
-
- return "[R" .. tostring(quality) .. "]"
+function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ local stack = stackKey and BUILTIN_STACKS[stackKey]
+ if not stack or stack.kind ~= "equipSlot" then return true end
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ if not itemId then return false end
+ local _, spellId = C_Item.GetItemSpell(itemId)
+ return spellId ~= nil
end
-local function buildTooltipLine(...)
- local parts = {}
- for i = 1, select("#", ...) do
- local value = select(i, ...)
- if value and value ~= "" then
- parts[#parts + 1] = value
+function ExtraIconsOptions._isRacialPresent(viewers, spellId)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if getEntrySpellId(entry) == spellId then return true end
end
end
-
- return table.concat(parts, " ")
-end
-
-local function setTooltipTitle(text, wrap)
- GameTooltip:SetText(text, 1, 1, 1, 1, wrap == true)
+ return false
end
-local function addTooltipLine(text, wrap)
- if text and text ~= "" then
- GameTooltip:AddLine(text, 1, 1, 1, wrap == true)
- end
+function ExtraIconsOptions._isCurrentRacialEntry(entry)
+ local racialSpellId = getCurrentRacialSpellId()
+ return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
end
-local function addItemStackTooltipLines(entry)
- local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
- if not stack or stack.kind ~= "item" or not stack.ids or #stack.ids == 0 then
- return false
- end
-
- addTooltipLine(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"], true)
-
- for _, itemEntry in ipairs(stack.ids) do
- local itemId = getItemIdFromEntry(itemEntry)
- local icon = itemId and C_Item.GetItemIconByID(itemId) or nil
- local itemName = getItemDisplayName(itemId)
- local quality = type(itemEntry) == "table" and itemEntry.quality or nil
- addTooltipLine(
- buildTooltipLine(
- getTextureMarkup(icon, TOOLTIP_ITEM_ICON_SIZE),
- itemName or ("Item " .. tostring(itemId)),
- getQualityMarkup(quality)
- )
- )
+function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ local spellId = getEntrySpellId(entry)
+ if not spellId then return true end
+ local racial = getCurrentRacialEntry()
+ if not racial then return true end
+ for _, racialEntry in pairs(RACIAL_ABILITIES) do
+ if racialEntry ~= racial and spellId == racialEntry.spellId then return false end
end
-
return true
end
---- Get display name for a config entry.
+--------------------------------------------------------------------------------
+-- Entry Display
+--------------------------------------------------------------------------------
+
function ExtraIconsOptions._getEntryName(entry)
if entry.stackKey then
local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then
- return entry.stackKey
- end
-
+ if not stack then return entry.stackKey end
if stack.kind == "equipSlot" then
- local itemName = getEquippedItemDisplayName(stack.slotId)
- if itemName then
- return ("%s [%s]"):format(stack.label, itemName)
- end
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ local itemName = itemId and getItemDisplayName(itemId)
+ if itemName then return ("%s [%s]"):format(stack.label, itemName) end
end
-
return stack.label
end
if entry.kind == "spell" and entry.ids then
- local first = entry.ids[1]
- local spellId = type(first) == "table" and first.spellId or first
+ local spellId = getEntrySpellId(entry)
local spellAPI = type(C_Spell) == "table" and C_Spell or nil
local name = spellId and spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(spellId)
return name or ("Spell " .. tostring(spellId))
end
if entry.kind == "item" and entry.ids then
- local first = entry.ids[1]
- return getItemDisplayName(getItemIdFromEntry(first))
+ return getItemDisplayName(getItemIdFromEntry(entry.ids[1]))
end
return "Unknown"
end
-local function getEntryTooltipTitle(entry)
- local name = ExtraIconsOptions._getEntryName(entry)
- if type(entry) ~= "table" then
- return name
- end
-
- if entry.kind == "spell" then
- local spellId = getEntrySpellId(entry)
- if spellId then
- return string.format("%s (spell ID %s)", name, spellId)
- end
- elseif entry.kind == "item" and entry.ids and entry.ids[1] then
- local itemId = getItemIdFromEntry(entry.ids[1])
- if itemId then
- return string.format("%s (item ID %s)", name, itemId)
- end
- end
-
- return name
-end
-
---- Get display icon for a config entry.
function ExtraIconsOptions._getEntryIcon(entry)
if entry.stackKey then
local stack = BUILTIN_STACKS[entry.stackKey]
if not stack then return nil end
- if stack.kind == "equipSlot" then
- return GetInventoryItemTexture("player", stack.slotId)
- end
+ if stack.kind == "equipSlot" then return GetInventoryItemTexture("player", stack.slotId) end
if stack.ids and stack.ids[1] then
- local first = stack.ids[1]
- local itemId = type(first) == "table" and first.itemID or first
+ local itemId = getItemIdFromEntry(stack.ids[1])
return itemId and C_Item.GetItemIconByID(itemId)
end
return nil
end
- if entry.kind == "spell" and entry.ids then
- local first = entry.ids[1]
- local spellId = type(first) == "table" and first.spellId or first
+ if entry.kind == "spell" then
+ local spellId = getEntrySpellId(entry)
local spellAPI = type(C_Spell) == "table" and C_Spell or nil
return spellId and spellAPI and spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(spellId)
end
if entry.kind == "item" and entry.ids then
- local first = entry.ids[1]
- local itemId = type(first) == "table" and first.itemID or first
+ local itemId = getItemIdFromEntry(entry.ids[1])
return itemId and C_Item.GetItemIconByID(itemId)
end
return nil
end
---- Add a predefined stack entry to a viewer.
+local function getEntryTooltipTitle(entry)
+ local name = ExtraIconsOptions._getEntryName(entry)
+ if type(entry) ~= "table" then return name end
+ if entry.kind == "spell" then
+ local id = getEntrySpellId(entry)
+ if id then return ("%s (spell ID %s)"):format(name, id) end
+ elseif entry.kind == "item" and entry.ids and entry.ids[1] then
+ local id = getItemIdFromEntry(entry.ids[1])
+ if id then return ("%s (item ID %s)"):format(name, id) end
+ end
+ return name
+end
+
+local function showRowTooltip(owner, rowData)
+ if not rowData then return end
+ local displayEntry = rowData.displayEntry
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then GameTooltip:ClearLines() end
+ GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1, 1, false)
+ local function tip(text)
+ if text and text ~= "" then GameTooltip:AddLine(text, 1, 1, 1, true) end
+ end
+ if rowData.isBuiltin and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
+ end
+ if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ end
+ local stack = displayEntry.stackKey and BUILTIN_STACKS[displayEntry.stackKey]
+ if stack and stack.kind == "item" and stack.ids and #stack.ids > 0 then
+ tip(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"])
+ for _, itemEntry in ipairs(stack.ids) do
+ local itemId = getItemIdFromEntry(itemEntry)
+ local parts = {}
+ local icon = itemId and C_Item.GetItemIconByID(itemId)
+ if icon and type(CreateTextureMarkup) == "function" then
+ parts[#parts + 1] = CreateTextureMarkup(icon, 64, 64, 14, 14, 0, 1, 0, 1)
+ end
+ parts[#parts + 1] = getItemDisplayName(itemId) or ("Item " .. tostring(itemId))
+ local quality = type(itemEntry) == "table" and itemEntry.quality
+ if quality and type(CreateAtlasMarkup) == "function" then
+ parts[#parts + 1] = CreateAtlasMarkup("Professions-Icon-Quality-Tier" .. quality .. "-Small", 14, 14)
+ elseif quality then
+ parts[#parts + 1] = "[R" .. quality .. "]"
+ end
+ tip(table.concat(parts, " "))
+ end
+ end
+ GameTooltip:Show()
+end
+
+--------------------------------------------------------------------------------
+-- Entry Mutations
+--------------------------------------------------------------------------------
+
+local function appendToViewer(viewers, viewerKey, entry)
+ viewers[viewerKey] = viewers[viewerKey] or {}
+ viewers[viewerKey][#viewers[viewerKey] + 1] = entry
+end
+
function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
local viewers = profile.extraIcons.viewers
- if ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- return
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ appendToViewer(viewers, viewerKey, { stackKey = stackKey })
end
- viewers[viewerKey] = viewers[viewerKey] or {}
- viewers[viewerKey][#viewers[viewerKey] + 1] = { stackKey = stackKey }
end
---- Add a racial spell entry to a viewer.
function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
local viewers = profile.extraIcons.viewers
- if ExtraIconsOptions._isRacialPresent(viewers, spellId) then
- return
+ if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then
+ appendToViewer(viewers, viewerKey, { kind = "spell", ids = { spellId } })
end
- viewers[viewerKey] = viewers[viewerKey] or {}
- viewers[viewerKey][#viewers[viewerKey] + 1] = { kind = "spell", ids = { spellId } }
end
---- Add a custom entry to a viewer.
function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
local viewers = profile.extraIcons.viewers
viewers[viewerKey] = viewers[viewerKey] or {}
local entry = { kind = kind, ids = {} }
for _, id in ipairs(ids) do
- if kind == "item" then
- entry.ids[#entry.ids + 1] = { itemID = id }
- else
- entry.ids[#entry.ids + 1] = id
- end
+ entry.ids[#entry.ids + 1] = kind == "item" and { itemID = id } or id
end
- if ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
- return
+ if not ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
+ viewers[viewerKey][#viewers[viewerKey] + 1] = entry
end
- viewers[viewerKey][#viewers[viewerKey] + 1] = entry
end
---- Remove entry at index from a viewer.
function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
local entries = profile.extraIcons.viewers[viewerKey]
- if entries and index >= 1 and index <= #entries then
- table.remove(entries, index)
- end
+ if entries and index >= 1 and index <= #entries then table.remove(entries, index) end
end
function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
local entries = profile.extraIcons.viewers[viewerKey]
local entry = entries and entries[index]
- if not entry then
- return
- end
-
- entry.disabled = disabled and true or nil
+ if entry then entry.disabled = disabled and true or nil end
end
function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
if index then
- local entries = profile.extraIcons.viewers[viewerKey]
- local entry = entries and entries[index]
- if not entry then
- return
- end
-
- ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled)
- return
+ local entry = (profile.extraIcons.viewers[viewerKey] or {})[index]
+ if entry then ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled) end
+ else
+ ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
end
-
- ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
end
function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
if index then
ExtraIconsOptions._removeEntry(profile, viewerKey, index)
- return
- end
-
- if spellId then
+ elseif spellId then
ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
end
end
---- Swap entry with its neighbor (-1 = up, +1 = down).
+local function isVisibleActiveViewerEntry(entry)
+ return not isDisabledBuiltinEntry(entry)
+ and ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey))
+end
+
function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
local entries = profile.extraIcons.viewers[viewerKey]
if not entries then return end
- local target = index + direction
- while target >= 1 and target <= #entries and isDisabledBuiltinEntry(entries[target]) do
- target = target + direction
+ local visibleIndices, activeIndex = {}, nil
+ for i, entry in ipairs(entries) do
+ if isVisibleActiveViewerEntry(entry) then
+ visibleIndices[#visibleIndices + 1] = i
+ if i == index then activeIndex = #visibleIndices end
+ end
end
- if target < 1 or target > #entries then return end
- entries[index], entries[target] = entries[target], entries[index]
+ if not activeIndex then return end
+ local target = visibleIndices[activeIndex + direction]
+ if target then entries[index], entries[target] = entries[target], entries[index] end
end
---- Move entry from one viewer to another (appends at end).
function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
local from = profile.extraIcons.viewers[fromViewer]
if not from or index < 1 or index > #from then return end
- local candidateEntry = from[index]
- if ExtraIconsOptions._findDuplicateEntry(profile.extraIcons.viewers, candidateEntry, fromViewer, index) == toViewer then
- return
- end
+ if ExtraIconsOptions._findDuplicateEntry(profile.extraIcons.viewers, from[index], fromViewer, index) == toViewer then return end
local entry = table.remove(from, index)
local to = profile.extraIcons.viewers[toViewer] or {}
profile.extraIcons.viewers[toViewer] = to
to[#to + 1] = entry
end
---- Parse comma-separated numeric IDs from a string.
---- Returns array of numbers, or nil if any value is invalid.
-function ExtraIconsOptions._parseIds(text)
- if not text or text == "" then return nil end
- local ids = {}
- for part in text:gmatch("[^,]+") do
- local trimmed = part:match("^%s*(.-)%s*$")
- local num = tonumber(trimmed)
- if not num or num <= 0 or num ~= math.floor(num) then
- return nil
- end
- ids[#ids + 1] = num
- end
- return #ids > 0 and ids or nil
+function ExtraIconsOptions._otherViewer(viewerKey)
+ return viewerKey == "utility" and "main" or "utility"
end
---- Parse a single positive integer ID from a string.
----@param text string|nil
----@return number|nil
-function ExtraIconsOptions._parseSingleId(text)
- if not text or text == "" then
- return nil
- end
+--------------------------------------------------------------------------------
+-- Parsing and Resolution
+--------------------------------------------------------------------------------
+function ExtraIconsOptions._parseSingleId(text)
+ if not text or text == "" then return nil end
local num = tonumber(text)
- if not num or num <= 0 or num ~= math.floor(num) then
- return nil
- end
-
+ if not num or num <= 0 or num ~= math.floor(num) then return nil end
return num
end
---- Resolve a draft spell or item ID to preview data.
----@param kind string
----@param text string|nil
----@return string status "invalid"|"pending"|"resolved"
----@return string|nil name
----@return string|number|nil icon
function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
local id = ExtraIconsOptions._parseSingleId(text)
- if not id then
- return "invalid", nil, nil
- end
-
+ if not id then return "invalid", nil, nil end
if kind == "spell" then
local spellAPI = type(C_Spell) == "table" and C_Spell or nil
local name = spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(id)
- if not name then
- return "invalid", nil, nil
- end
-
+ if not name then return "invalid", nil, nil end
return "resolved", name, spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(id)
end
-
if kind == "item" then
- if not C_Item.DoesItemExistByID(id) then
- return "invalid", nil, nil
- end
-
+ if not C_Item.DoesItemExistByID(id) then return "invalid", nil, nil end
local name = C_Item.GetItemNameByID(id)
local icon = C_Item.GetItemIconByID(id)
if name then
ExtraIconsOptions._pendingItemLoads[id] = nil
return "resolved", name, icon
end
-
- markPendingItemLoad(id)
+ ExtraIconsOptions._pendingItemLoads[id] = true
C_Item.RequestLoadItemDataByID(id)
-
return "pending", nil, icon
end
-
return "invalid", nil, nil
end
---- Resolve a draft spell or item ID to a display name.
----@param kind string
----@param text string|nil
----@return string|nil
-function ExtraIconsOptions._resolveDraftEntryName(kind, text)
- local status, name = ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
- if status ~= "resolved" then
- return nil
- end
- return name
-end
+--------------------------------------------------------------------------------
+-- Duplicate Detection
+--------------------------------------------------------------------------------
local function getEntryIdentityKey(entry)
- if not entry then
- return nil
- end
-
- if entry.stackKey then
- return "stack:" .. tostring(entry.stackKey)
- end
-
- if entry.kind == "spell" and entry.ids and #entry.ids > 0 then
- local parts = { "spell" }
- for _, id in ipairs(entry.ids) do
- local spellId = type(id) == "table" and id.spellId or id
- parts[#parts + 1] = tostring(spellId)
- end
- return table.concat(parts, ":")
- end
-
- if entry.kind == "item" and entry.ids and #entry.ids > 0 then
- local parts = { "item" }
- for _, id in ipairs(entry.ids) do
+ if not entry then return nil end
+ if entry.stackKey then return "stack:" .. entry.stackKey end
+ if not (entry.kind and entry.ids and #entry.ids > 0) then return nil end
+ local parts = { entry.kind }
+ for _, id in ipairs(entry.ids) do
+ if entry.kind == "spell" then
+ parts[#parts + 1] = tostring(type(id) == "table" and id.spellId or id)
+ else
parts[#parts + 1] = tostring(getItemIdFromEntry(id))
end
- return table.concat(parts, ":")
end
-
- return nil
+ return table.concat(parts, ":")
end
function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
local candidateKey = getEntryIdentityKey(candidateEntry)
- if not candidateKey then
- return nil, nil
- end
-
+ if not candidateKey then return nil, nil end
for viewerKey, entries in pairs(viewers) do
for index, entry in ipairs(entries) do
if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
@@ -571,156 +393,70 @@ function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreVi
end
end
end
-
return nil, nil
end
function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- local viewerKey = ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- return viewerKey ~= nil
+ return ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) ~= nil
end
-local function buildDraftEntry(kind, id)
- if not id then
- return nil
- end
-
- if kind == "item" then
- return { kind = "item", ids = { { itemID = id } } }
- end
-
- if kind == "spell" then
- return { kind = "spell", ids = { id } }
- end
-
- return nil
-end
+--------------------------------------------------------------------------------
+-- Row Building
+--------------------------------------------------------------------------------
function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
- local activeRows = {}
- local entries = viewers[viewerKey] or {}
- local disabledBuiltinRows = {}
-
- for index, entry in ipairs(entries) do
- if ExtraIconsOptions._isRacialForCurrentPlayer(entry) then
+ local activeRows, disabledBuiltinRows = {}, {}
+ for index, entry in ipairs(viewers[viewerKey] or {}) do
+ if ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
local rowData = {
- rowType = "entry",
- viewerKey = viewerKey,
- index = index,
- entry = entry,
- displayEntry = entry,
+ rowType = "entry", viewerKey = viewerKey, index = index,
+ entry = entry, displayEntry = entry,
isBuiltin = entry.stackKey ~= nil,
isCurrentRacial = ExtraIconsOptions._isCurrentRacialEntry(entry),
- isPlaceholder = false,
- isDisabled = entry.disabled == true,
+ isPlaceholder = false, isDisabled = entry.disabled == true,
}
-
if isDisabledBuiltinEntry(entry) then
- disabledBuiltinRows[entry.stackKey] = disabledBuiltinRows[entry.stackKey] or {}
- disabledBuiltinRows[entry.stackKey][#disabledBuiltinRows[entry.stackKey] + 1] = rowData
+ local bucket = disabledBuiltinRows[entry.stackKey] or {}
+ disabledBuiltinRows[entry.stackKey] = bucket
+ bucket[#bucket + 1] = rowData
else
activeRows[#activeRows + 1] = rowData
end
end
end
-
- for activeIndex, rowData in ipairs(activeRows) do
- rowData.activeIndex = activeIndex
+ for i, rowData in ipairs(activeRows) do
+ rowData.activeIndex = i
rowData.activeCount = #activeRows
end
-
local rows = activeRows
-
for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
local bucket = disabledBuiltinRows[stackKey]
if bucket then
- for _, rowData in ipairs(bucket) do
- rows[#rows + 1] = rowData
- end
- elseif viewerKey == DEFAULT_SPECIAL_VIEWER and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ for _, rowData in ipairs(bucket) do rows[#rows + 1] = rowData end
+ elseif viewerKey == DEFAULT_SPECIAL_VIEWER
+ and ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
rows[#rows + 1] = {
- rowType = "builtinPlaceholder",
- viewerKey = viewerKey,
- stackKey = stackKey,
- displayEntry = { stackKey = stackKey },
- isBuiltin = true,
- isCurrentRacial = false,
- isPlaceholder = true,
- isDisabled = true,
+ rowType = "builtinPlaceholder", viewerKey = viewerKey, stackKey = stackKey,
+ displayEntry = { stackKey = stackKey },
+ isBuiltin = true, isCurrentRacial = false, isPlaceholder = true, isDisabled = true,
}
end
end
-
if viewerKey == DEFAULT_SPECIAL_VIEWER then
local racialSpellId = getCurrentRacialSpellId()
if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
rows[#rows + 1] = {
- rowType = "racialPlaceholder",
- viewerKey = viewerKey,
- spellId = racialSpellId,
+ rowType = "racialPlaceholder", viewerKey = viewerKey, spellId = racialSpellId,
displayEntry = { kind = "spell", ids = { racialSpellId } },
- isBuiltin = false,
- isCurrentRacial = true,
- isPlaceholder = true,
- isDisabled = true,
+ isBuiltin = false, isCurrentRacial = true, isPlaceholder = true, isDisabled = true,
}
end
end
-
return rows
end
---- Get the opposite viewer key.
-function ExtraIconsOptions._otherViewer(viewerKey)
- return viewerKey == "utility" and "main" or "utility"
-end
-
---------------------------------------------------------------------------------
--- UI: Tooltip helpers
---------------------------------------------------------------------------------
-
-local function showRowTooltip(owner, rowData)
- if not rowData then
- return
- end
-
- local displayEntry = rowData.displayEntry
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- setTooltipTitle(getEntryTooltipTitle(displayEntry))
-
- if rowData.isBuiltin then
- if rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], true)
- end
- elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"], true)
- end
-
- if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
- addTooltipLine(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"], true)
- end
-
- addItemStackTooltipLines(displayEntry)
- GameTooltip:Show()
-end
-
---- Check if a racial entry belongs to the current player character.
-function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- local spellId = getEntrySpellId(entry)
- if not spellId then return true end
- local racial = getCurrentRacialEntry()
- if not racial then return true end
- for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry ~= racial and spellId == racialEntry.spellId then
- return false
- end
- end
- return true
-end
-
--------------------------------------------------------------------------------
-- Settings Registration
--------------------------------------------------------------------------------
@@ -733,29 +469,24 @@ function ExtraIconsOptions.RegisterSettings(SB)
local categoryName = L["EXTRA_ICONS"]
local category
- local function getProfile()
- return ns.Addon.db.profile
+ local function getProfile() return ns.Addon.db.profile end
+ local function getViewers() return getProfile().extraIcons.viewers end
+ local function refreshCategory()
+ if category then SB.RefreshCategory(category) else SB.RefreshCategory(categoryName) end
end
-
- local function getViewers()
- return getProfile().extraIcons.viewers
+ local function doAction(fn)
+ if fn then fn() end
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ refreshCategory()
end
-
- local function refreshCategory()
- if category then
- SB.RefreshCategory(category)
- else
- SB.RefreshCategory(categoryName)
- end
+ local function getViewerShortLabel(viewerKey)
+ return viewerKey == "utility" and L["UTILITY_VIEWER_SHORT"] or L["MAIN_VIEWER_SHORT"]
end
ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
local draftStates = ExtraIconsOptions._draftStates
for _, viewerKey in ipairs(VIEWER_ORDER) do
- draftStates[viewerKey] = draftStates[viewerKey] or {
- kind = "spell",
- idText = "",
- }
+ draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
end
local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
@@ -764,132 +495,82 @@ function ExtraIconsOptions.RegisterSettings(SB)
ExtraIconsOptions._itemLoadFrame = itemLoadFrame
end
if not itemLoadFrame._ecmHooked then
- if itemLoadFrame.RegisterEvent then
- itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
- end
- itemLoadFrame:SetScript("OnEvent", function(_, _, itemId)
- if itemId and ExtraIconsOptions._pendingItemLoads[itemId] then
- ExtraIconsOptions._pendingItemLoads[itemId] = nil
+ itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
+ itemLoadFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
+ itemLoadFrame:SetScript("OnEvent", function(_, event, arg1)
+ if event == "GET_ITEM_INFO_RECEIVED" and arg1 and ExtraIconsOptions._pendingItemLoads[arg1] then
+ ExtraIconsOptions._pendingItemLoads[arg1] = nil
+ refreshCategory()
+ elseif event == "PLAYER_EQUIPMENT_CHANGED" and BUILTIN_EQUIP_SLOTS[arg1] then
refreshCategory()
end
end)
itemLoadFrame._ecmHooked = true
end
- local function scheduleUpdate()
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end
-
- local function resetDraftStates()
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- draftStates[viewerKey].kind = "spell"
- draftStates[viewerKey].idText = ""
- end
- end
-
- local function getDraftResolution(viewerKey)
- local draftState = draftStates[viewerKey]
- return ExtraIconsOptions._resolveDraftEntryPreview(draftState.kind, draftState.idText)
- end
-
- local function getDraftPlaceholderText(draftState)
- if draftState.kind == "spell" then
- return L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"]
- end
-
- return L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
+ local function buildDraftEntry(kind, id)
+ if not id then return nil end
+ if kind == "item" then return { kind = "item", ids = { { itemID = id } } } end
+ if kind == "spell" then return { kind = "spell", ids = { id } } end
+ return nil
end
local function getDraftDuplicateInfo(viewerKey)
- local draftState = draftStates[viewerKey]
- local id = ExtraIconsOptions._parseSingleId(draftState.idText)
- local entry = buildDraftEntry(draftState.kind, id)
- local duplicateViewerKey = entry and ExtraIconsOptions._findDuplicateEntry(getViewers(), entry) or nil
- return duplicateViewerKey ~= nil, duplicateViewerKey
- end
-
- local function canAddDraftEntry(viewerKey)
- local status = getDraftResolution(viewerKey)
- if status ~= "resolved" then
- return false
- end
-
- local isDuplicate = getDraftDuplicateInfo(viewerKey)
- return not isDuplicate
+ local ds = draftStates[viewerKey]
+ local entry = buildDraftEntry(ds.kind, ExtraIconsOptions._parseSingleId(ds.idText))
+ local dupViewer = entry and ExtraIconsOptions._findDuplicateEntry(getViewers(), entry) or nil
+ return dupViewer ~= nil, dupViewer
end
local function addDraftEntry(viewerKey)
- local draftState = draftStates[viewerKey]
- local id = ExtraIconsOptions._parseSingleId(draftState.idText)
- if not id or not canAddDraftEntry(viewerKey) then
- return false
- end
-
- ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, draftState.kind, { id })
- draftState.idText = ""
- scheduleUpdate()
- refreshCategory()
+ local ds = draftStates[viewerKey]
+ local status = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ if status ~= "resolved" or getDraftDuplicateInfo(viewerKey) then return false end
+ local id = ExtraIconsOptions._parseSingleId(ds.idText)
+ ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, ds.kind, { id })
+ ds.idText = ""
+ doAction()
return true
end
- local function restoreDefaultExtraIcons()
- local defaultsProfile = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
- local defaultsConfig = defaultsProfile and defaultsProfile.extraIcons
- if not defaultsConfig then
- return
- end
-
- ns.Addon.db.profile.extraIcons = ns.CloneValue(defaultsConfig)
- resetDraftStates()
- scheduleUpdate()
- refreshCategory()
+ local function makeAction(text, textures, enabled, tooltip, onClick)
+ return {
+ text = text, width = ACTION_ICON_BUTTON_SIZE, height = ACTION_ICON_BUTTON_SIZE,
+ buttonTextures = textures, enabled = enabled, tooltip = tooltip, onClick = onClick,
+ }
end
local function buildActionItem(rowData)
local controlsDisabled = isDisabled()
local displayEntry = rowData.displayEntry
local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
- local duplicateViewerKey = rowData.index ~= nil
- and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index)
- or nil
- local hasMoveDuplicate = duplicateViewerKey == otherViewer
- local positionLocked = rowData.isBuiltin and rowData.isDisabled
- local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not positionLocked
- local canMove = not controlsDisabled and rowData.index ~= nil and not positionLocked and not hasMoveDuplicate
- local deleteText = "x"
- local deleteTooltip = L["REMOVE_TOOLTIP"]
- local deleteAction = function()
- local entryName = ExtraIconsOptions._getEntryName(displayEntry)
- StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", entryName, nil, {
- onAccept = function()
+ local dupViewer = rowData.index ~= nil
+ and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index) or nil
+ local hasMoveDup = dupViewer == otherViewer
+ local posLocked = rowData.isBuiltin and rowData.isDisabled
+ local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
+ local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
+
+ local delText, delTex, delTip = "x", ACTION_BUTTON_TEXTURES.delete, L["REMOVE_TOOLTIP"]
+ local delAction = function()
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
+ onAccept = function() doAction(function()
ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
- scheduleUpdate()
- refreshCategory()
- end,
+ end) end,
})
end
-
if rowData.isBuiltin then
- deleteText = rowData.isDisabled and "+" or "x"
- deleteTooltip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
- deleteAction = function()
- ExtraIconsOptions._toggleBuiltinRow(
- getProfile(),
- rowData.viewerKey,
- rowData.index,
- rowData.stackKey or displayEntry.stackKey
- )
- scheduleUpdate()
- refreshCategory()
- end
+ delText = rowData.isDisabled and "+" or "x"
+ delTex = rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide
+ delTip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
+ delAction = function() doAction(function()
+ ExtraIconsOptions._toggleBuiltinRow(getProfile(), rowData.viewerKey, rowData.index, rowData.stackKey or displayEntry.stackKey)
+ end) end
elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- deleteText = "+"
- deleteTooltip = L["ADD_ENTRY"]
- deleteAction = function()
+ delText, delTex, delTip = "+", ACTION_BUTTON_TEXTURES.show, L["ADD_ENTRY"]
+ delAction = function() doAction(function()
ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
- scheduleUpdate()
- refreshCategory()
- end
+ end) end
end
return {
@@ -900,218 +581,114 @@ function ExtraIconsOptions.RegisterSettings(SB)
labelColor = rowData.isDisabled and { 0.65, 0.65, 0.65, 1 } or { 1, 0.82, 0, 1 },
iconDesaturated = rowData.isDisabled == true,
iconVertexColor = rowData.isDisabled and { 0.6, 0.6, 0.6, 1 } or nil,
- onEnter = function(owner)
- showRowTooltip(owner, rowData)
- end,
- onLeave = function()
- GameTooltip_Hide()
- end,
+ onEnter = function(owner) showRowTooltip(owner, rowData) end,
+ onLeave = function() GameTooltip_Hide() end,
actions = {
- up = {
- text = "^",
- width = 30,
- enabled = canReorder and rowData.activeIndex > 1,
- tooltip = L["MOVE_UP_TOOLTIP"],
- onClick = function()
- if rowData.index == nil or not canReorder then
- return
- end
-
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
- scheduleUpdate()
- refreshCategory()
- end,
- },
- down = {
- text = "v",
- width = 30,
- enabled = canReorder and rowData.activeIndex < rowData.activeCount,
- tooltip = L["MOVE_DOWN_TOOLTIP"],
- onClick = function()
- if rowData.index == nil or not canReorder then
- return
- end
-
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
- scheduleUpdate()
- refreshCategory()
- end,
- },
- move = {
- text = rowData.viewerKey == "utility" and ">" or "<",
- width = 30,
- enabled = canMove,
- tooltip = function()
- if hasMoveDuplicate then
- return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
- end
- if positionLocked then
- return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
- end
+ up = makeAction("^", ACTION_BUTTON_TEXTURES.moveUp, canReorder and rowData.activeIndex > 1, L["MOVE_UP_TOOLTIP"],
+ function() doAction(function() ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1) end) end),
+ down = makeAction("v", ACTION_BUTTON_TEXTURES.moveDown, canReorder and rowData.activeIndex < rowData.activeCount, L["MOVE_DOWN_TOOLTIP"],
+ function() doAction(function() ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1) end) end),
+ move = makeAction(rowData.viewerKey == "utility" and ">" or "<", ACTION_BUTTON_TEXTURES.swap, canMove,
+ function()
+ if hasMoveDup then return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer)) end
+ if posLocked then return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"] end
return L["MOVE_TO_VIEWER_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
end,
- onClick = function()
- if rowData.index == nil or not canMove then
- return
- end
-
- ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
- scheduleUpdate()
- refreshCategory()
- end,
- },
- delete = {
- text = deleteText,
- width = 26,
- enabled = not controlsDisabled,
- tooltip = deleteTooltip,
- onClick = deleteAction,
- },
+ function() doAction(function() ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index) end) end),
+ delete = makeAction(delText, delTex, not controlsDisabled, delTip, delAction),
},
}
end
local function buildModeInputTrailer(viewerKey)
- local draftState = draftStates[viewerKey]
+ local ds = draftStates[viewerKey]
local function getPreviewState()
- local status, name, icon = getDraftResolution(viewerKey)
- local isDuplicate, duplicateViewerKey = getDraftDuplicateInfo(viewerKey)
- return status, name, icon, isDuplicate, duplicateViewerKey
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ local isDup, dupViewer = getDraftDuplicateInfo(viewerKey)
+ return status, name, icon, isDup, dupViewer
+ end
+ local function toggleKind()
+ if isDisabled() then return false end
+ ds.kind = ds.kind == "spell" and "item" or "spell"
+ return true
end
-
return {
- preset = "modeInput",
- disabled = function()
- return isDisabled()
- end,
- modeText = function()
- return draftState.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"]
- end,
+ type = "modeInput",
+ disabled = isDisabled,
+ modeText = function() return ds.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"] end,
modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
- inputText = function()
- return draftState.idText
- end,
+ inputText = function() return ds.idText end,
placeholder = function()
- return getDraftPlaceholderText(draftState)
- end,
- previewIcon = function()
- local _, _, icon = getPreviewState()
- return icon
+ return ds.kind == "spell" and L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] or L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
end,
+ previewIcon = function() local _, _, icon = getPreviewState(); return icon end,
previewText = function()
- local status, name, _, isDuplicate, duplicateViewerKey = getPreviewState()
- if status == "resolved" and isDuplicate then
- return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(duplicateViewerKey))
- end
- if status == "resolved" then
- return name or ""
- end
- if status == "pending" then
- return DRAFT_PENDING_TEXT
- end
+ local status, name, _, isDup, dupViewer = getPreviewState()
+ if status == "resolved" and isDup then return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(dupViewer)) end
+ if status == "resolved" then return name or "" end
+ if status == "pending" then return "..." end
return nil
end,
submitText = L["ADD_ENTRY"],
submitTooltip = L["ADD_ENTRY"],
submitEnabled = function()
- local status, _, _, isDuplicate = getPreviewState()
- return status == "resolved" and not isDuplicate
- end,
- onToggleMode = function()
- if isDisabled() then
- return
- end
-
- draftState.kind = draftState.kind == "spell" and "item" or "spell"
- end,
- onTextChanged = function(text)
- draftState.idText = text or ""
+ local s, _, _, d = getPreviewState()
+ return s == "resolved" and not d
end,
+ onToggleMode = toggleKind,
+ onTextChanged = function(text) ds.idText = text or "" end,
onSubmit = function()
- if isDisabled() then
- return false
- end
-
+ if isDisabled() then return false end
return addDraftEntry(viewerKey)
end,
- onTabPressed = function()
- if isDisabled() then
- return false
- end
-
- draftState.kind = draftState.kind == "spell" and "item" or "spell"
- return true
- end,
+ onTabPressed = toggleKind,
}
end
- local function buildViewerSections()
- local viewers = getViewers()
- local sections = {}
-
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- local rows = ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
- local items = {}
-
- for _, rowData in ipairs(rows) do
- items[#items + 1] = buildActionItem(rowData)
- end
-
- sections[#sections + 1] = {
- key = viewerKey,
- title = L[VIEWER_LABELS[viewerKey]],
- items = items,
- emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
- trailer = buildModeInputTrailer(viewerKey),
- }
- end
-
- return sections
- end
-
- ExtraIconsOptions._viewerCanvas = nil
- ExtraIconsOptions._draftEntryCanvas = nil
- ExtraIconsOptions._addFormCanvas = nil
- ExtraIconsOptions._presetsCanvas = nil
ExtraIconsOptions._refresh = refreshCategory
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = categoryName,
path = "extraIcons",
- onShow = function()
- ns.Runtime.SetLayoutPreview(true)
- end,
- onHide = function()
- ns.Runtime.SetLayoutPreview(false)
- end,
- args = {
- enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"],
- desc = L["ENABLE_EXTRA_ICONS_DESC"],
- order = 0,
- onSet = function(value)
- local handler = ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")
- handler(value)
- end,
+ onShow = function() ns.Runtime.SetLayoutPreview(true) end,
+ onHide = function() ns.Runtime.SetLayoutPreview(false) end,
+ rows = {
+ {
+ id = "enabled", type = "checkbox", path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"], desc = L["ENABLE_EXTRA_ICONS_DESC"],
+ onSet = function(value) ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(value) end,
},
- specialRowsLegend = {
- type = "info",
- name = "",
+ {
+ id = "specialRowsLegend", type = "info", name = "",
value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
- wide = true,
- multiline = true,
- height = 24,
- order = 10,
+ wide = true, multiline = true, height = 24,
},
- viewers = {
- type = "collection",
- height = VIEWER_COLLECTION_HEIGHT,
+ {
+ id = "viewers", type = "sectionList", height = VIEWER_COLLECTION_HEIGHT,
disabled = isDisabled,
- sections = buildViewerSections,
- onDefault = restoreDefaultExtraIcons,
- order = 11,
+ sections = function()
+ local viewers = getViewers()
+ local sections = {}
+ for _, vk in ipairs(VIEWER_ORDER) do
+ local items = {}
+ for _, rowData in ipairs(ExtraIconsOptions._buildViewerRows(viewers, vk)) do
+ items[#items + 1] = buildActionItem(rowData)
+ end
+ sections[#sections + 1] = {
+ key = vk, title = L[VIEWER_LABELS[vk]], items = items,
+ emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
+ footer = buildModeInputTrailer(vk),
+ }
+ end
+ return sections
+ end,
+ onDefault = function()
+ local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ if not (defaults and defaults.extraIcons) then return end
+ ns.Addon.db.profile.extraIcons = ns.CloneValue(defaults.extraIcons)
+ for _, vk in ipairs(VIEWER_ORDER) do draftStates[vk].kind = "spell"; draftStates[vk].idText = "" end
+ doAction()
+ end,
},
},
})
diff --git a/UI/GeneralOptions.lua b/UI/GeneralOptions.lua
index 6bdf42fa..3b97f661 100644
--- a/UI/GeneralOptions.lua
+++ b/UI/GeneralOptions.lua
@@ -14,35 +14,33 @@ end
local GeneralOptions = {}
function GeneralOptions.RegisterSettings(SB)
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["GENERAL"],
path = "global",
- args = {
+ rows = {
-- Visibility
- visHeader = { type = "header", name = L["VISIBILITY"], order = 10 },
- hideWhenMounted = {
- type = "toggle",
+ { type = "header", name = L["VISIBILITY"] },
+ {
+ type = "checkbox",
path = "hideWhenMounted",
name = L["HIDE_WHEN_MOUNTED"],
desc = L["HIDE_WHEN_MOUNTED_DESC"],
- order = 11,
},
- hideInRestAreas = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "hideOutOfCombatInRestAreas",
name = L["HIDE_IN_REST_AREAS"],
desc = L["HIDE_IN_REST_AREAS_DESC"],
- order = 12,
},
- fade = {
- type = "toggle",
+ {
+ id = "fade",
+ type = "checkbox",
path = "global.outOfCombatFade.enabled",
name = L["FADE_OUT_OF_COMBAT"],
desc = L["FADE_OUT_OF_COMBAT_DESC"],
- order = 13,
},
- fadeOpacity = {
- type = "range",
+ {
+ type = "slider",
path = "global.outOfCombatFade.opacity",
name = L["OUT_OF_COMBAT_OPACITY"],
desc = L["OUT_OF_COMBAT_OPACITY_DESC"],
@@ -50,50 +48,44 @@ function GeneralOptions.RegisterSettings(SB)
max = 100,
step = 5,
parent = "fade",
- order = 14,
},
- fadeExceptInstance = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "global.outOfCombatFade.exceptInInstance",
name = L["EXCEPT_INSIDE_INSTANCES"],
parent = "fade",
- order = 15,
},
- fadeExceptHostile = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "global.outOfCombatFade.exceptIfTargetCanBeAttacked",
name = L["EXCEPT_TARGET_HOSTILE"],
parent = "fade",
- order = 16,
},
- fadeExceptFriendly = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "global.outOfCombatFade.exceptIfTargetCanBeHelped",
name = L["EXCEPT_TARGET_FRIENDLY"],
parent = "fade",
- order = 17,
},
-- Appearance
- appearHeader = { type = "header", name = L["APPEARANCE"], order = 20 },
- texture = {
+ { type = "header", name = L["APPEARANCE"] },
+ {
type = "custom",
path = "texture",
name = L["BAR_TEXTURE"],
desc = L["BAR_TEXTURE_DESC"],
template = LSMW.TEXTURE_PICKER_TEMPLATE,
- order = 21,
},
- font = {
+ {
type = "custom",
path = "font",
name = L["FONT"],
desc = L["FONT_DESC"],
template = LSMW.FONT_PICKER_TEMPLATE,
- order = 22,
},
- fontSize = {
- type = "range",
+ {
+ type = "slider",
path = "fontSize",
name = L["FONT_SIZE"],
min = 6,
@@ -102,10 +94,9 @@ function GeneralOptions.RegisterSettings(SB)
getTransform = function(value)
return value or 11
end,
- order = 23,
},
- fontOutline = {
- type = "select",
+ {
+ type = "dropdown",
path = "fontOutline",
name = L["FONT_OUTLINE"],
values = {
@@ -114,27 +105,24 @@ function GeneralOptions.RegisterSettings(SB)
THICKOUTLINE = L["FONT_OUTLINE_THICK"],
MONOCHROME = L["FONT_OUTLINE_MONOCHROME"],
},
- order = 24,
},
- fontShadow = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "fontShadow",
name = L["FONT_SHADOW"],
desc = L["FONT_SHADOW_DESC"],
- order = 25,
},
-- Sizing
- layoutHeader = { type = "header", name = L["SIZING"], order = 30 },
- barHeight = {
- type = "range",
+ { type = "header", name = L["SIZING"] },
+ {
+ type = "slider",
path = "barHeight",
name = L["BAR_HEIGHT"],
desc = L["BAR_HEIGHT_DESC"],
min = 10,
max = 40,
step = 1,
- order = 31,
},
},
})
@@ -145,31 +133,29 @@ ns.SettingsBuilder.RegisterSection(ns, "General", GeneralOptions)
local AdvancedOptions = {}
function AdvancedOptions.RegisterSettings(SB)
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["ADVANCED_OPTIONS"],
path = "global",
- args = {
- troubleshootHeader = { type = "header", name = L["TROUBLESHOOTING"], order = 10 },
- debug = {
- type = "toggle",
+ rows = {
+ { type = "header", name = L["TROUBLESHOOTING"] },
+ {
+ type = "checkbox",
path = "debug",
name = L["DEBUG_MODE"],
desc = L["DEBUG_MODE_DESC"],
- order = 11,
},
- debugToChat = {
- type = "toggle",
+ {
+ type = "checkbox",
path = "debugToChat",
name = L["DEBUG_TO_CHAT"],
desc = L["DEBUG_TO_CHAT_DESC"],
- order = 12,
disabled = function()
local gc = getGlobalConfig()
return not (gc and gc.debug)
end,
},
- updatesHeader = { type = "header", name = L["UPDATES"], order = 20 },
- showWhatsNew = {
+ { type = "header", name = L["UPDATES"] },
+ {
type = "button",
name = " ",
buttonText = L["SHOW_WHATS_NEW"],
@@ -179,18 +165,16 @@ function AdvancedOptions.RegisterSettings(SB)
ns.Addon:ShowReleasePopup(true)
end
end,
- order = 21,
},
- perfHeader = { type = "header", name = L["PERFORMANCE"], order = 30 },
- updateFrequency = {
- type = "range",
+ { type = "header", name = L["PERFORMANCE"] },
+ {
+ type = "slider",
path = "updateFrequency",
name = L["UPDATE_FREQUENCY"],
desc = L["UPDATE_FREQUENCY_DESC"],
min = 0.04,
max = 0.5,
step = 0.02,
- order = 31,
},
},
})
diff --git a/UI/LayoutOptions.lua b/UI/LayoutOptions.lua
index 4d2cb485..82c24447 100644
--- a/UI/LayoutOptions.lua
+++ b/UI/LayoutOptions.lua
@@ -8,9 +8,9 @@ local L = ns.L
local LayoutOptions = {}
-local function createAnchorModeSpec(name, path, order, disabled)
+local function createAnchorModeSpec(name, path, disabled)
return {
- type = "select",
+ type = "dropdown",
path = path,
name = name,
desc = L["POSITION_MODE_DESC"],
@@ -20,7 +20,6 @@ local function createAnchorModeSpec(name, path, order, disabled)
[C.ANCHORMODE_FREE] = L["POSITION_MODE_FREE"],
},
disabled = disabled,
- order = order,
}
end
@@ -32,33 +31,35 @@ function LayoutOptions.RegisterSettings(SB)
local runeBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
local buffBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
- local args = {
- positioningExamples = {
+ local rows = {
+ {
type = "canvas",
canvas = ns.OptionUtil.CreatePositioningExamplesCanvas(),
height = C.POSITION_MODE_EXPLAINER_HEIGHT,
- order = 0,
},
-
- moduleHeader = { type = "header", name = L["MODULE_LAYOUT_HEADER"], order = 10 },
- powerBarMode = createAnchorModeSpec(L["POWER_BAR"], "powerBar.anchorMode", 11, powerBarDisabled),
- resourceBarMode = createAnchorModeSpec(L["RESOURCE_BAR"], "resourceBar.anchorMode", 12, resourceBarDisabled),
- runeBarMode = createAnchorModeSpec(L["RUNE_BAR"], "runeBar.anchorMode", 13, runeBarDisabled),
- buffBarsMode = createAnchorModeSpec(L["AURA_BARS"], "buffBars.anchorMode", 14, buffBarsDisabled),
-
- attachedHeader = { type = "header", name = L["POSITION_MODE_ATTACHED"], order = 20 },
- offsetY = {
- type = "range",
+ {
+ type = "header",
+ name = L["MODULE_LAYOUT_HEADER"],
+ },
+ createAnchorModeSpec(L["POWER_BAR"], "powerBar.anchorMode", powerBarDisabled),
+ createAnchorModeSpec(L["RESOURCE_BAR"], "resourceBar.anchorMode", resourceBarDisabled),
+ createAnchorModeSpec(L["RUNE_BAR"], "runeBar.anchorMode", runeBarDisabled),
+ createAnchorModeSpec(L["AURA_BARS"], "buffBars.anchorMode", buffBarsDisabled),
+ {
+ type = "header",
+ name = L["POSITION_MODE_ATTACHED"],
+ },
+ {
+ type = "slider",
path = "global.offsetY",
name = L["VERTICAL_OFFSET"],
desc = L["VERTICAL_OFFSET_DESC"],
min = 0,
max = 20,
step = 1,
- order = 21,
},
- moduleSpacing = {
- type = "range",
+ {
+ type = "slider",
path = "global.moduleSpacing",
name = L["VERTICAL_SPACING"],
desc = L["VERTICAL_SPACING_DESC"],
@@ -66,10 +67,9 @@ function LayoutOptions.RegisterSettings(SB)
max = 20,
step = 1,
getTransform = defaultZero,
- order = 22,
},
- moduleGrowDirection = {
- type = "select",
+ {
+ type = "dropdown",
path = "global.moduleGrowDirection",
name = L["GROW_DIRECTION"],
desc = L["GROW_DIRECTION_ATTACHED_DESC"],
@@ -78,15 +78,14 @@ function LayoutOptions.RegisterSettings(SB)
[C.GROW_DIRECTION_UP] = L["UP"],
},
getTransform = defaultDetachedGrowDirection,
- order = 23,
},
}
- for key, spec in pairs(ns.OptionUtil.CreateDetachedStackArgs()) do
- args[key] = spec
+ for _, row in ipairs(ns.OptionUtil.CreateDetachedStackRows()) do
+ rows[#rows + 1] = row
end
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["LAYOUT_SUBCATEGORY"],
onShow = function()
ns.Runtime.SetLayoutPreview(true)
@@ -94,7 +93,7 @@ function LayoutOptions.RegisterSettings(SB)
onHide = function()
ns.Runtime.SetLayoutPreview(false)
end,
- args = args,
+ rows = rows,
})
end
diff --git a/UI/OptionUtil.lua b/UI/OptionUtil.lua
index 261a4abd..0ad7e350 100644
--- a/UI/OptionUtil.lua
+++ b/UI/OptionUtil.lua
@@ -226,6 +226,7 @@ end
---@param hasOpacity boolean
---@param onChange fun(color: {r:number, g:number, b:number, a:number})
function OptionUtil.OpenColorPicker(currentColor, hasOpacity, onChange)
+ local isSettingUp = true
ColorPickerFrame:SetupColorPickerAndShow({
r = currentColor.r,
g = currentColor.g,
@@ -233,6 +234,7 @@ function OptionUtil.OpenColorPicker(currentColor, hasOpacity, onChange)
opacity = currentColor.a,
hasOpacity = hasOpacity,
swatchFunc = function()
+ if isSettingUp then return end
local r, g, b = ColorPickerFrame:GetColorRGB()
local a = hasOpacity and ColorPickerFrame:GetColorAlpha() or 1
onChange({ r = r, g = g, b = b, a = a })
@@ -241,6 +243,7 @@ function OptionUtil.OpenColorPicker(currentColor, hasOpacity, onChange)
onChange({ r = prev.r, g = prev.g, b = prev.b, a = hasOpacity and prev.opacity or 1 })
end,
})
+ isSettingUp = false
end
--- Returns a closure that checks if the module at configPath is disabled.
@@ -294,41 +297,44 @@ function OptionUtil.CreateModuleEnabledHandler(moduleName, requiresReload)
end
end
---- Generates standard layout and appearance args shared by bar-type modules.
+--- Generates standard layout and appearance rows shared by bar-type modules.
+--- This is the canonical rows-array form used by RegisterPage pages.
---@param isDisabled fun(): boolean
----@param options table|nil { showText: boolean, border: boolean, layoutOrder: number, appearanceOrder: number }
----@return table args Partial args table to merge into RegisterFromTable
-function OptionUtil.CreateBarArgs(isDisabled, options)
+---@param options table|nil { showText: boolean, border: boolean }
+---@return table[] rows
+function OptionUtil.CreateBarRows(isDisabled, options)
options = options or {}
- local layoutOrder = options.layoutOrder or 10
- local appearanceOrder = options.appearanceOrder or 20
- local breadcrumbArgs = OptionUtil.CreateLayoutBreadcrumbArgs(layoutOrder)
-
- local args = {
- layoutMovedButton = breadcrumbArgs.layoutMovedButton,
- appearanceHeader = { type = "header", name = L["APPEARANCE"], disabled = isDisabled, order = appearanceOrder },
- heightOverride = { type = "heightOverride", disabled = isDisabled, order = appearanceOrder + 1 },
- fontOverride = { type = "fontOverride", disabled = isDisabled, order = appearanceOrder + 2 },
+ local rows = {
+ OptionUtil.CreateLayoutBreadcrumbArgs(10).layoutMovedButton,
+ {
+ type = "header",
+ name = L["APPEARANCE"],
+ disabled = isDisabled,
+ },
}
if options.showText ~= false then
- args.showText = {
- type = "toggle",
+ rows[#rows + 1] = {
+ type = "checkbox",
path = "showText",
name = L["SHOW_TEXT"],
desc = L["SHOW_TEXT_DESC"],
disabled = isDisabled,
- order = appearanceOrder + 1,
}
- args.heightOverride.order = appearanceOrder + 2
- args.fontOverride.order = appearanceOrder + 3
end
+ rows[#rows + 1] = { type = "heightOverride", disabled = isDisabled }
+ rows[#rows + 1] = { type = "fontOverride", disabled = isDisabled }
+
if options.border ~= false then
- args.border = { type = "border", path = "border", disabled = isDisabled, order = args.fontOverride.order + 1 }
+ rows[#rows + 1] = {
+ type = "border",
+ path = "border",
+ disabled = isDisabled,
+ }
end
- return args
+ return rows
end
local function createDetachedSettingSpecs()
@@ -367,40 +373,40 @@ local function createDetachedSettingSpecs()
}
end
-function OptionUtil.CreateDetachedStackArgs()
- local defaultZero = OptionUtil.CreateDefaultValueTransform(0)
- local args = {
- detachedHeader = { type = "header", name = L["POSITION_MODE_DETACHED"], order = 30 },
+function OptionUtil.CreateDetachedStackRows()
+ local rows = {
+ {
+ type = "header",
+ name = L["POSITION_MODE_DETACHED"],
+ },
}
- local order = 31
+ local defaultZero = OptionUtil.CreateDefaultValueTransform(0)
for _, spec in ipairs(createDetachedSettingSpecs()) do
- local arg = {
+ local row = {
path = "global." .. spec.key,
name = spec.name,
desc = spec.desc,
- order = order,
getTransform = spec.default == 0 and defaultZero or OptionUtil.CreateDefaultValueTransform(spec.default),
}
if spec.values then
- arg.type = "select"
- arg.values = {}
+ row.type = "dropdown"
+ row.values = {}
for _, option in ipairs(spec.values) do
- arg.values[option.value] = option.text
+ row.values[option.value] = option.text
end
else
- arg.type = "range"
- arg.min = spec.min
- arg.max = spec.max
- arg.step = spec.step
+ row.type = "slider"
+ row.min = spec.min
+ row.max = spec.max
+ row.step = spec.step
end
- args[spec.key] = arg
- order = order + 1
+ rows[#rows + 1] = row
end
- return args
+ return rows
end
function OptionUtil.CreateDetachedAnchorEditModeSettings(getGlobalConfig, onChanged)
diff --git a/UI/Options.lua b/UI/Options.lua
index dec34678..5242912b 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -65,50 +65,44 @@ function About.RegisterSettings(SB)
local version = (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
local authorText = ns.ColorUtil.Sparkle("Argi")
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["ADDON_NAME"],
rootCategory = true,
- args = {
- author = {
+ rows = {
+ {
type = "info",
name = L["AUTHOR"],
value = authorText,
- order = 1,
},
- contributors = {
+ {
type = "info",
name = L["CONTRIBUTORS"],
value = "kayti-wow",
- order = 2,
},
- version = {
+ {
type = "info",
name = L["VERSION"],
value = version,
- order = 3,
},
- linksHeader = {
- type = "description",
+ {
+ type = "subheader",
name = L["LINKS"],
- order = 9,
},
- curseforge = {
+ {
type = "button",
name = L["CURSEFORGE"],
buttonText = L["CURSEFORGE"],
onClick = function()
ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
end,
- order = 10,
},
- github = {
+ {
type = "button",
name = L["GITHUB"],
buttonText = L["GITHUB"],
onClick = function()
ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
end,
- order = 11,
},
},
})
diff --git a/UI/PowerBarOptions.lua b/UI/PowerBarOptions.lua
index 180e9255..fe777934 100644
--- a/UI/PowerBarOptions.lua
+++ b/UI/PowerBarOptions.lua
@@ -21,32 +21,35 @@ local PowerBarOptions = {}
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("powerBar")
function PowerBarOptions.RegisterSettings(SB)
- local args = ns.OptionUtil.CreateBarArgs(isDisabled)
- args.enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_POWER_BAR"],
- order = 0,
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar"),
+ local rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_POWER_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar"),
+ },
}
- args.showManaAsPercent = {
- type = "toggle",
+
+ for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
+ rows[#rows + 1] = row
+ end
+
+ rows[#rows + 1] = {
+ type = "checkbox",
path = "showManaAsPercent",
name = L["SHOW_MANA_AS_PERCENT"],
desc = L["SHOW_MANA_AS_PERCENT_DESC"],
disabled = isDisabled,
- order = 22,
}
- args.colors = {
+ rows[#rows + 1] = {
type = "colorList",
path = "colors",
label = L["COLORS"],
defs = POWER_COLOR_DEFS,
disabled = isDisabled,
- order = 30,
}
- SB.RegisterFromTable({ name = L["POWER_BAR"], path = "powerBar", args = args })
+ SB.RegisterPage({ name = L["POWER_BAR"], path = "powerBar", rows = rows })
ns.PowerBarTickMarksOptions.RegisterSettings(SB, SB._currentSubcategory)
end
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index ecd546bd..4f6dc920 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -216,12 +216,13 @@ function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
return items
end
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = categoryName,
parentCategory = parentCategory,
- args = {
- tickMarksHeader = {
- type = "header",
+ rows = {
+ {
+ id = "tickMarksPageActions",
+ type = "pageActions",
name = categoryName,
actions = {
{
@@ -237,27 +238,27 @@ function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
end,
},
},
- order = 1,
},
- description = {
+ {
+ id = "description",
type = "info",
name = "",
value = L["TICK_MARKS_DESC"],
wide = true,
multiline = true,
height = 36,
- order = 2,
},
- summary = {
+ {
+ id = "summary",
type = "info",
name = "",
value = getTickSummary,
wide = true,
multiline = true,
height = 28,
- order = 3,
},
- defaultColor = {
+ {
+ id = "defaultColor",
type = "color",
key = "tickMarksDefaultColor",
name = L["DEFAULT_COLOR"],
@@ -271,10 +272,10 @@ function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
onSet = function()
refreshCategory()
end,
- order = 4,
},
- defaultWidth = {
- type = "range",
+ {
+ id = "defaultWidth",
+ type = "slider",
key = "tickMarksDefaultWidth",
name = L["DEFAULT_WIDTH"],
default = 1,
@@ -290,9 +291,9 @@ function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
onSet = function()
refreshCategory()
end,
- order = 5,
},
- addTick = {
+ {
+ id = "addTick",
type = "button",
name = L["ADD_TICK_MARK"],
buttonText = L["ADD"],
@@ -301,15 +302,14 @@ function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
scheduleUpdate()
refreshCategory()
end,
- order = 6,
},
- tickCollection = {
- type = "collection",
- preset = "editor",
+ {
+ id = "tickCollection",
+ type = "list",
+ variant = "editor",
height = 320,
rowHeight = C.SCROLL_ROW_HEIGHT_WITH_CONTROLS,
items = buildTickCollectionItems,
- order = 7,
},
},
})
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index 91ec6a6d..c2439be7 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -123,7 +123,7 @@ function ProfileOptions.RegisterSettings(SB)
local _, switchSetting = SB.Dropdown({
category = cat,
- key = "ECM_ProfileSwitch",
+ key = "ProfileSwitch",
name = L["SWITCH_PROFILE"],
tooltip = L["SWITCH_PROFILE_DESC"],
default = ns.Addon.db:GetCurrentProfile(),
@@ -173,7 +173,7 @@ function ProfileOptions.RegisterSettings(SB)
end
local _, getCopyProfile, clearCopyProfile =
- createProfilePicker(SB, cat, "ECM_ProfileCopy", L["COPY_FROM"], L["COPY_FROM_DESC"], otherProfilesGenerator)
+ createProfilePicker(SB, cat, "ProfileCopy", L["COPY_FROM"], L["COPY_FROM_DESC"], otherProfilesGenerator)
SB.Button({
name = L["COPY"],
@@ -197,7 +197,7 @@ function ProfileOptions.RegisterSettings(SB)
local _, getDeleteProfile, clearDeleteProfile = createProfilePicker(
SB,
cat,
- "ECM_ProfileDelete",
+ "ProfileDelete",
L["DELETE_PROFILE"],
L["DELETE_PROFILE_SELECT_DESC"],
otherProfilesGenerator
diff --git a/UI/ResourceBarOptions.lua b/UI/ResourceBarOptions.lua
index 36618dcb..2b455d37 100644
--- a/UI/ResourceBarOptions.lua
+++ b/UI/ResourceBarOptions.lua
@@ -65,13 +65,13 @@ local ResourceBarOptions = {}
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("resourceBar")
function ResourceBarOptions.RegisterSettings(SB)
- local args = ns.OptionUtil.CreateBarArgs(isDisabled)
- args.enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_RESOURCE_BAR"],
- order = 0,
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("ResourceBar"),
+ local rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_RESOURCE_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("ResourceBar"),
+ },
}
local maxColorDefs = {}
for _, def in ipairs(RESOURCE_COLOR_DEFS) do
@@ -84,38 +84,38 @@ function ResourceBarOptions.RegisterSettings(SB)
end
end
- args.colorsHeader = { type = "header", name = L["COLORS"], disabled = isDisabled, order = 29 }
+ for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
+ rows[#rows + 1] = row
+ end
- args.colors = {
+ rows[#rows + 1] = { type = "header", name = L["COLORS"], disabled = isDisabled }
+ rows[#rows + 1] = {
type = "colorList",
path = "colors",
label = L["RESOURCE_TYPES"],
defs = RESOURCE_COLOR_DEFS,
disabled = isDisabled,
- order = 30,
}
- args.maxColorsEnabled = {
- type = "toggleList",
+ rows[#rows + 1] = {
+ type = "checkboxList",
path = "maxColorsEnabled",
label = L["USE_ALTERNATE_COLOR_WHEN_CAPPED"],
defs = maxColorDefs,
disabled = isDisabled,
- order = 31,
}
- args.maxColors = {
+ rows[#rows + 1] = {
type = "colorList",
path = "maxColors",
label = L["ALTERNATE_COLORS"],
defs = maxColorDefs,
disabled = isDisabled,
- order = 32,
}
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["RESOURCE_BAR"],
path = "resourceBar",
disabled = ns.IsDeathKnight,
- args = args,
+ rows = rows,
})
end
diff --git a/UI/RuneBarOptions.lua b/UI/RuneBarOptions.lua
index b4afa304..fe1ff68a 100644
--- a/UI/RuneBarOptions.lua
+++ b/UI/RuneBarOptions.lua
@@ -8,76 +8,81 @@ local RuneBarOptions = {}
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
function RuneBarOptions.RegisterSettings(SB)
- local args = ns.OptionUtil.CreateBarArgs(isDisabled, { showText = false, border = false })
- args.dkWarning = {
- type = "subheader",
- name = L["DK_ONLY_WARNING"],
- condition = function()
- return not ns.IsDeathKnight()
- end,
- order = 0,
+ local rows = {
+ {
+ type = "subheader",
+ name = L["DK_ONLY_WARNING"],
+ condition = function()
+ return not ns.IsDeathKnight()
+ end,
+ },
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_RUNE_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("RuneBar"),
+ },
}
- args.enabled = {
- type = "toggle",
- path = "enabled",
- name = L["ENABLE_RUNE_BAR"],
- order = 1,
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("RuneBar"),
+
+ for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled, { showText = false, border = false })) do
+ rows[#rows + 1] = row
+ end
+
+ rows[#rows + 1] = {
+ id = "colorLabel",
+ type = "subheader",
+ name = L["COLORS"],
+ disabled = isDisabled,
}
- args.colorLabel = { type = "subheader", name = L["COLORS"], disabled = isDisabled, order = 30 }
- args.useSpecColor = {
+ rows[#rows + 1] = {
+ id = "useSpecColor",
type = "checkbox",
path = "useSpecColor",
name = L["USE_SPEC_COLOR"],
desc = L["USE_SPEC_COLOR_DESC"],
parent = "colorLabel",
disabled = isDisabled,
- order = 31,
}
- args.runeColor = {
+ rows[#rows + 1] = {
type = "color",
path = "color",
name = L["RUNE_COLOR"],
parent = "useSpecColor",
parentCheck = "notChecked",
disabled = isDisabled,
- order = 32,
}
- args.bloodColor = {
+ rows[#rows + 1] = {
type = "color",
path = "colorBlood",
name = L["BLOOD_COLOR"],
parent = "useSpecColor",
parentCheck = "checked",
disabled = isDisabled,
- order = 33,
}
- args.frostColor = {
+ rows[#rows + 1] = {
type = "color",
path = "colorFrost",
name = L["FROST_COLOR"],
parent = "useSpecColor",
parentCheck = "checked",
disabled = isDisabled,
- order = 34,
}
- args.unholyColor = {
+ rows[#rows + 1] = {
type = "color",
path = "colorUnholy",
name = L["UNHOLY_COLOR"],
parent = "useSpecColor",
parentCheck = "checked",
disabled = isDisabled,
- order = 35,
}
- SB.RegisterFromTable({
+ SB.RegisterPage({
name = L["RUNE_BAR"],
path = "runeBar",
disabled = function()
return not ns.IsDeathKnight()
end,
- args = args,
+ rows = rows,
})
end
From a37fe233c7f77ad4220aa9e2b64ed16a6dc25a86 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 14 Apr 2026 17:24:54 +1000
Subject: [PATCH 13/53] Update locale and remove stupid function checks.
---
ECM.lua | 4 +---
ImportExport.lua | 6 +++---
Locales/en.lua | 45 +++++++++++++++++++++------------------------
3 files changed, 25 insertions(+), 30 deletions(-)
diff --git a/ECM.lua b/ECM.lua
index 17ae28d6..a11241bb 100644
--- a/ECM.lua
+++ b/ECM.lua
@@ -43,9 +43,7 @@ function ns.IsDeathKnight()
end
local function getAddonVersion()
- if C_AddOns and type(C_AddOns.GetAddOnMetadata) == "function" then
- return C_AddOns.GetAddOnMetadata(ADDON_NAME, C.ADDON_METADATA_VERSION_KEY)
- end
+ return C_AddOns.GetAddOnMetadata(ADDON_NAME, C.ADDON_METADATA_VERSION_KEY)
end
local function safeStrTostring(x)
diff --git a/ImportExport.lua b/ImportExport.lua
index 4a789df9..e9d2fb1f 100644
--- a/ImportExport.lua
+++ b/ImportExport.lua
@@ -133,7 +133,7 @@ end
function ImportExport.ExportCurrentProfile()
local db = ns.Addon.db
if not db or not db.profile then
- return nil, L["IMPORT_NO_PROFILE"]
+ return nil, L["EXPORT_NO_PROFILE"]
end
local exportData = prepareProfileForExport(db.profile)
@@ -166,12 +166,12 @@ end
---@return string|nil errorMessage Error message if apply failed
function ImportExport.ApplyImportData(data)
if not data or not data.profile then
- return false, L["IMPORT_INVALID_DATA"]
+ return false, L["IMPORT_NO_PROFILE_DATA"]
end
local db = ns.Addon.db
if not db or not db.profile then
- return false, L["IMPORT_NO_ACTIVE_PROFILE"]
+ return false, L["IMPORT_NO_PROFILE"]
end
-- Preserve the cache if it exists (deep copy to avoid shared references)
diff --git a/Locales/en.lua b/Locales/en.lua
index 96e3d7bf..27ae8d07 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -195,11 +195,10 @@ L["SPELL_COLORS_DESC"] =
"Customize colors for individual spells. Spells that are tracked in the cooldown manager as bars will automatically appear here."
L["SPELL_COLORS_SECRET_NAMES_DESC"] =
"One or more spell names have become secret. This can be cleared by reloading the UI outside of restricted area, typically dungeons, raids, delves, and PVP."
-L["SPELL_COLORS_RELOAD_BUTTON"] = "Reload UI"
L["SPELL_COLORS_RECONCILE_BUTTON"] = "Reconcile"
L["SPELL_COLORS_REMOVE_STALE_BUTTON"] = "Remove Stale"
L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"] =
- "Remove partial or stale entries that were discovered while spell information was secret. Reconcile should be attempted before this."
+ "Remove partial or stale entries that were added while spell information was secret. Reconcile should be attempted before this."
L["SPELL_COLORS_DONT_REMOVE"] = "Don't Remove"
L["SPELL_COLORS_REMOVED_STALE_ENTRY"] = "Removed stale spell color entry: %s"
L["SPELL_COLORS_COMBAT_WARNING"] = "|cffFF0000These settings cannot be changed while in combat lockdown.|r"
@@ -305,15 +304,14 @@ L["ADVANCED_OPTIONS"] = "Advanced Options"
L["TROUBLESHOOTING"] = "Troubleshooting"
L["UPDATES"] = "Updates"
L["DEBUG_MODE"] = "Debug Mode"
-L["DEBUG_MODE_DESC"] = "Log diagnostic information about the addon's internal operations to the DevTools addon."
+L["DEBUG_MODE_DESC"] = "Log diagnostic information about the addon's internal operations to the DevTools addon. Increases CPU usage."
L["DEBUG_TO_CHAT"] = "Debug to Chat"
L["DEBUG_TO_CHAT_DESC"] = "Print debug messages to the chat frame too."
-L["SHOW_WHATS_NEW"] = "Show What's New"
-L["SHOW_WHATS_NEW_DESC"] = "Open the What's New popup."
+L["WHATS_NEW"] = "What's New"
L["PERFORMANCE"] = "Performance"
L["UPDATE_FREQUENCY"] = "Update Frequency"
L["UPDATE_FREQUENCY_DESC"] =
- "How often (in seconds) to refresh bar displays. Lower values are smoother but use more CPU."
+ "How often (in seconds) to refresh bar displays. Lower values appear smoother but use more CPU."
--------------------------------------------------------------------------------
-- Profile Options
@@ -356,20 +354,20 @@ L["EXPORT_FAILED"] = "Export failed: %s"
-- Chat Commands
--------------------------------------------------------------------------------
-L["CMD_HELP_CLEARSEEN"] = "/ecm clearseen - clear the What's New seen flag (reload or relog to show it again)"
-L["CMD_HELP_DEBUG"] = "/ecm debug [on|off|toggle] - toggle debug mode (logs detailed info to the chat frame)"
+L["CMD_HELP_CLEARSEEN"] = "/ecm clearseen - clear the flag indicating the whats new popup was seen"
+L["CMD_HELP_DEBUG"] = "/ecm debug [on | off | toggle] - toggle debug mode (logs detailed info to the chat frame)"
L["CMD_HELP_EVENTS"] = "/ecm events [reset] - show or reset event fire counts"
L["CMD_HELP_HELP"] = "/ecm help - show this message"
L["CMD_HELP_MIGRATION"] = "/ecm migration - show migration info and commands"
L["CMD_HELP_OPTIONS"] = "/ecm options|config|settings|o - open the options menu"
L["CMD_HELP_REFRESH"] = "/ecm rl|reload|refresh - refresh and reapply layout for all modules"
L["REFRESHING_ALL_MODULES"] = "Refreshing all modules."
-L["OPTIONS_BLOCKED_COMBAT"] = "Options cannot be opened during combat. They will open when combat ends."
+L["OPTIONS_BLOCKED_COMBAT"] = "Options cannot be opened during combat. It will open when combat ends."
L["MIGRATION_LOG_TITLE"] = "Migration Log"
L["MIGRATION_LOG_EMPTY"] = "No migration log entries."
L["MIGRATION_ROLLBACK_USAGE"] = "Usage: /ecm migration rollback "
L["VERSION_ZERO_INVALID"] = "Version 0 is not valid."
-L["DEBUG_USAGE"] = "Usage: expected on|off|toggle"
+L["DEBUG_USAGE"] = "Usage: expected on | off | toggle"
L["DEBUG_STATUS"] = "Debug:"
L["DEBUG_ON"] = "ON"
L["DEBUG_OFF"] = "OFF"
@@ -402,18 +400,17 @@ L["RELOAD_UI_PROMPT"] = "Reload the UI?"
-- Import / Export Errors
--------------------------------------------------------------------------------
-L["ENCODE_NO_DATA"] = "No data provided for encoding"
-L["ENCODE_SERIALIZATION_FAILED"] = "Serialization failed"
-L["ENCODE_COMPRESSION_FAILED"] = "Compression failed"
-L["ENCODE_ENCODING_FAILED"] = "Encoding failed"
+L["ENCODE_NO_DATA"] = "Internal error: no data provided for encoding - please report this"
+L["ENCODE_SERIALIZATION_FAILED"] = "Failed to generate encoded string (serialization error)"
+L["ENCODE_COMPRESSION_FAILED"] = "Failed to generate encoded string (compression error)"
+L["ENCODE_ENCODING_FAILED"] = "Failed to generate encoded string (encoding error)"
L["DECODE_EMPTY"] = "Import string is empty"
-L["DECODE_INVALID_FORMAT"] = "Invalid import string format"
-L["DECODE_WRONG_ADDON"] = "This import string is not for Enhanced Cooldown Manager (prefix: %s)"
-L["DECODE_INCOMPATIBLE_VERSION"] = "Incompatible import string version (expected %d, got %s)"
-L["DECODE_CORRUPTED"] = "Failed to decode string - it may be corrupted or incomplete"
-L["DECODE_DECOMPRESS_FAILED"] = "Failed to decompress data - the string may be corrupted"
-L["DECODE_DESERIALIZE_FAILED"] = "Failed to deserialize data - the string may be corrupted"
-L["IMPORT_NO_PROFILE"] = "No active profile found"
-L["IMPORT_NO_PROFILE_DATA"] = "Import string does not contain profile data"
-L["IMPORT_INVALID_DATA"] = "Invalid import data"
-L["IMPORT_NO_ACTIVE_PROFILE"] = "No active profile to import into"
+L["DECODE_INVALID_FORMAT"] = "Provided string is not a valid ECM import string"
+L["DECODE_WRONG_ADDON"] = "Provided string is not a valid ECM import string (prefix: %s)"
+L["DECODE_INCOMPATIBLE_VERSION"] = "Provided string is not a valid ECM import string (expected %d, got %s)"
+L["DECODE_CORRUPTED"] = "Provided string is not a valid ECM import string - it may be corrupted or incomplete"
+L["DECODE_DECOMPRESS_FAILED"] = "Provided string is not a valid ECM import string - it may be corrupted or incomplete"
+L["DECODE_DESERIALIZE_FAILED"] = "Provided string is not a valid ECM import string - it may be corrupted or incomplete"
+L["EXPORT_NO_PROFILE"] = "Internal error: no active profile found - please report this"
+L["IMPORT_NO_PROFILE_DATA"] = "Provided string is not a valid ECM import string (profile data missing)"
+L["IMPORT_NO_PROFILE"] = "Internal error: no active profile to import into - please report this"
From bb85e918f29510f65477bc45968cbba1eda51edf Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:01:15 +1000
Subject: [PATCH 14/53] checkpoint
---
.codex | 0
.../extraicons/options-dsl-and-migration.md | 28 -
.serena/memories/style_and_conventions.md | 5 -
AGENTS.md | 2 +-
ARCHITECTURE.md | 18 +-
EnhancedCooldownManager.toc | 1 +
Libs/LibEvent/LibEvent.lua | 4 -
.../CompositeControls/Groups.lua | 291 +++--
.../CompositeControls/Lists.lua | 45 +-
Libs/LibSettingsBuilder/Controls/Base.lua | 369 ++++---
.../Controls/Collections.lua | 73 +-
Libs/LibSettingsBuilder/Controls/Rows.lua | 203 ++--
Libs/LibSettingsBuilder/Core.lua | 697 ++++++------
Libs/LibSettingsBuilder/Primitives/Layout.lua | 81 +-
Libs/LibSettingsBuilder/README.md | 68 +-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 730 +++++++++----
.../Tests/Collections_spec.lua | 16 +-
Libs/LibSettingsBuilder/Utility.lua | 997 ++++++++++++++----
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 94 +-
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 2 +-
.../docs/MIGRATION_GUIDE.md | 8 +-
Libs/LibSettingsBuilder/docs/QUICK_START.md | 170 ++-
.../docs/TROUBLESHOOTING.md | 4 +-
Locales/en.lua | 2 -
Tests/ChatCommand_spec.lua | 2 +-
Tests/ImportExport_spec.lua | 16 +-
Tests/TestHelpers.lua | 75 +-
Tests/UI/About_spec.lua | 34 +-
Tests/UI/AdvancedOptions_spec.lua | 4 +-
Tests/UI/BuffBarsOptions_spec.lua | 138 ++-
Tests/UI/BuffBarsSettingsOptions_spec.lua | 11 +-
Tests/UI/ExtraIconsOptions_spec.lua | 27 +-
Tests/UI/GeneralOptions_spec.lua | 2 +-
Tests/UI/LayoutOptions_spec.lua | 22 +-
Tests/UI/OptionsSections_spec.lua | 280 ++---
Tests/UI/Options_spec.lua | 66 +-
Tests/UI/PowerBarOptions_spec.lua | 26 +-
Tests/UI/PowerBarTickMarksOptions_spec.lua | 193 +++-
Tests/UI/PowerBarTickMarksStore_spec.lua | 102 --
Tests/UI/ProfileOptions_spec.lua | 15 +-
Tests/UI/ResourceBarOptions_spec.lua | 12 +-
Tests/UI/RuneBarOptions_spec.lua | 12 +-
UI/BuffBarsOptions.lua | 180 ++--
UI/ExtraIconsOptions.lua | 731 +------------
UI/ExtraIconsOptionsUtil.lua | 901 ++++++++++++++++
UI/GeneralOptions.lua | 326 +++---
UI/LayoutOptions.lua | 137 ++-
UI/OptionUtil.lua | 7 +-
UI/Options.lua | 154 ++-
UI/PowerBarOptions.lua | 64 +-
UI/PowerBarTickMarksOptions.lua | 423 ++++----
UI/ProfileOptions.lua | 158 ++-
UI/ResourceBarOptions.lua | 99 +-
UI/RuneBarOptions.lua | 145 ++-
54 files changed, 4569 insertions(+), 3701 deletions(-)
create mode 100644 .codex
delete mode 100644 .serena/memories/extraicons/options-dsl-and-migration.md
delete mode 100644 Tests/UI/PowerBarTickMarksStore_spec.lua
create mode 100644 UI/ExtraIconsOptionsUtil.lua
diff --git a/.codex b/.codex
new file mode 100644
index 00000000..e69de29b
diff --git a/.serena/memories/extraicons/options-dsl-and-migration.md b/.serena/memories/extraicons/options-dsl-and-migration.md
deleted file mode 100644
index 65dff0fc..00000000
--- a/.serena/memories/extraicons/options-dsl-and-migration.md
+++ /dev/null
@@ -1,28 +0,0 @@
-# ExtraIcons / Options DSL / SpellColors refresh
-
-## ExtraIcons replaced ItemIcons
-- `Modules/ItemIcons.lua` and `UI/ItemIconsOptions.lua` were removed and replaced by `Modules/ExtraIcons.lua` and `UI/ExtraIconsOptions.lua`.
-- The profile model changed from boolean flags under `profile.itemIcons` to `profile.extraIcons = { enabled, viewers = { utility = {...}, main = {...} } }`.
-- `Constants.EXTRAICONS` maps to config key `extraIcons`; `Constants.MODULE_ORDER` now includes `ExtraIcons`.
-- Defaults seed `extraIcons.viewers.utility` with builtin stack entries for `trinket1`, `trinket2`, `combatPotions`, `healthPotions`, and `healthstones`.
-- Migration V12 is the frozen conversion from legacy `itemIcons` flags into ordered `extraIcons.viewers.utility` entries and then deletes `profile.itemIcons`.
-
-## Runtime coupling
-- `ExtraIcons` is not treated like a purely independent trailing module. `Runtime.updateAllLayouts()` runs `ExtraIcons:UpdateLayout()` before the chained bar modules so width-sensitive bars can anchor against the final widened main-viewer footprint.
-- `ExtraIcons:GetMainViewerAnchor()` returns a synthetic anchor frame when the module extends the main Blizzard viewer; otherwise it falls back to the Blizzard frame.
-- `ExtraIcons` manages two viewers (`utility`, `main`), tracks icon pools per viewer, hooks viewer/show/size events, and refreshes cooldowns from bag/equipment/spell events.
-
-## ExtraIcons options UI
-- `UI/ExtraIconsOptions.lua` is fully data-driven through `LibSettingsBuilder`.
-- The central `RegisterSettings(SB)` builds a sectioned `collection` with one section per viewer, each containing action rows (reorder, move between viewers, hide/show builtin row, delete custom row).
-- The add-entry flow uses draft state per viewer, a mode-input trailer, duplicate detection, and async item-name refresh via `GET_ITEM_INFO_RECEIVED` + `SB.RefreshCategory(...)`.
-- Builtin rows and the current racial ability are represented as special rows instead of separate bespoke controls.
-
-## LibSettingsBuilder expansion
-- `Libs/LibSettingsBuilder/LibSettingsBuilder.lua` now supports richer row types and behaviors used across ECM options: `header`, `subheader`, `info`, `input`, `button`, `collection`, embedded canvas rows, sectioned collections, swatch/editor presets, action buttons, and inline slider editing.
-- Prefer extending the existing DSL-driven options flow instead of hand-building new settings-frame widgets when working on complex options screens.
-
-## SpellColors / BuffBars options
-- `SpellColors.lua` now owns the keyed buff-bar color model. Persisted entries are class/spec-scoped and keyed by name/spellID/cooldownID/texture tiers.
-- `SpellColors.GetAllColorEntries()` merges persisted rows with runtime-discovered keys so `UI/BuffBarsOptions.lua` can render active bars without poking into `BuffBars` internals.
-- Buff-bar spell colors UI now uses a `collection` with swatch rows plus header actions for reset, reconcile/reload, and stale-entry cleanup.
diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md
index 84a95227..6782678c 100644
--- a/.serena/memories/style_and_conventions.md
+++ b/.serena/memories/style_and_conventions.md
@@ -28,11 +28,6 @@
- Shared confirm dialogs should use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`.
- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code.
-## Options / UI Patterns
-- Current options pages prefer the `LibSettingsBuilder` DSL/table registration flow.
-- Complex pages use collection-style rows (`header`, `subheader`, `info`, `button`, `collection`, `input`) and refresh through `SB.RefreshCategory(...)`.
-- Dynamic option UIs should keep logic in the owning options module rather than reaching into unrelated modules.
-
## Tests / Secrets
- Be skeptical about changing tests to satisfy failures.
- Test load order should mirror TOC load order.
diff --git a/AGENTS.md b/AGENTS.md
index 858628de..423dac2f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -69,7 +69,7 @@ luacheck . -q
- Inline single-use local functions into their sole call site. Do not extract a helper just to give a three-line block a name.
- When multiple table literals share identical structure (e.g. `{ normal = base .. X .. "_normal", pushed = base .. X .. "_down" }`), generate them with a constructor instead of writing each one out.
-- When the same two- or three-call sequence repeats across many callbacks (e.g. `scheduleUpdate(); refreshCategory()`), extract one thin wrapper and call it everywhere.
+- When the same two- or three-call sequence repeats across many callbacks (e.g. `scheduleUpdate(); refreshPage()`), extract one thin wrapper and call it everywhere.
- Use `O(1)` set lookups (`SET[key]`) instead of linear scans (`for i, v in ipairs(list)`) when the list is fixed at load time.
- Prefer compact single-line bodies for trivial functions: `local function f() return x end`.
- When building repetitive declarative structures (action buttons, menu items), extract a factory that takes only the varying parts and returns the full structure.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 33d41f52..a0a08168 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -194,11 +194,21 @@ LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced
Setting changes flow through LibSettingsBuilder's `onChange` → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`.
The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, which guarantees `Core.lua`, the primitive helper modules, standard control modules, composite control modules, and `Utility.lua` initialize in order before options pages register.
-Options pages now use `SB.RegisterPage(...)` plus canonical row types:
+Options pages now use LibSettingsBuilder as a single declarative registration tree:
+
+- `SB.GetRoot(L["ADDON_NAME"])` returns the singleton root handle,
+- each options module exports plain section/page spec tables (`ns.GeneralOptions`, `ns.PowerBarOptions`, `ns.AboutPage`, etc.) instead of registering itself,
+- `UI/Options.lua` combines those specs and calls `root:Register({ page = ns.AboutPage, sections = { ... } })` once,
+- `root:Register(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
+- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes.
+
+Deprecated non-declarative page-construction APIs were removed from the builder surface. ECM settings pages are registered through the root tree only.
+
+Pages still use the same canonical row types:
- standard persisted controls (`checkbox`, `slider`, `dropdown`, `color`, `input`) bind through path mode or handler mode,
- layout rows (`header`, `subheader`, `info`, `button`, `pageActions`, `canvas`) handle structure and copy,
-- dynamic editors use `list` and `sectionList` rows with library-owned rendering plus `SB.RefreshCategory(...)` for async or transient state such as profile pickers and item-preview updates,
+- dynamic editors use `list` and `sectionList` rows with library-owned rendering,
- `canvas` rows stay on the existing lifecycle path so page switches do not lose or misplace embedded content.
### Watchdog Ticker
@@ -390,7 +400,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Uses `SB.RegisterPage(...)` for the enabled proxy setting and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the canvas as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is also synthesized as a disabled placeholder when absent; if race lookup misses, the fallback known-racial scan uses the same spellbook-aware check as runtime resolution so racials like Shadowmeld still surface in the settings list. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the category so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Canvas rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Registers through the new root/section/page API and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is also synthesized as a disabled placeholder when absent; if race lookup misses, the fallback known-racial scan uses the same spellbook-aware check as runtime resolution so racials like Shadowmeld still surface in the settings list. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the page so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Canvas rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
### FrameUtil (`ns.FrameUtil`)
@@ -491,7 +501,7 @@ Shared helpers for the Settings UI, used by all option pages.
| `CreateDetachedAnchorEditModeSettings(getGlobalConfig, onChanged)` | Create Edit Mode settings for detached anchor |
| `OpenColorPicker(currentColor, hasOpacity, onChange)` | Open Blizzard color picker |
| `MakeConfirmDialog(text)` | Create confirm dialog for `StaticPopup` |
-| `OpenLayoutPage()` | Open settings to Layout subcategory |
+| `OpenLayoutPage()` | Open settings to the registered Layout page |
### ECM Addon Instance
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 9950cc21..34b9ec8b 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -55,5 +55,6 @@ UI\PowerBarOptions.lua
UI\ResourceBarOptions.lua
UI\RuneBarOptions.lua
UI\ProfileOptions.lua
+UI\ExtraIconsOptionsUtil.lua
UI\ExtraIconsOptions.lua
UI\BuffBarsOptions.lua
diff --git a/Libs/LibEvent/LibEvent.lua b/Libs/LibEvent/LibEvent.lua
index 0d4d1738..5295fbb5 100644
--- a/Libs/LibEvent/LibEvent.lua
+++ b/Libs/LibEvent/LibEvent.lua
@@ -104,10 +104,6 @@ local function createInstance(target)
local instance = LibEvent.embeds[target]
if type(instance) ~= "table" then
instance = { _events = {}, _stats = {} }
- else
- -- Preserve existing events and stats on re-embed (library upgrade)
- instance._events = instance._events or {}
- instance._stats = instance._stats or {}
end
instance.frame = instance.frame or CreateFrame("Frame")
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
index eea8e3fe..d2881a69 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
@@ -8,159 +8,154 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-function lib._installCompositeGroups(SB, env)
- local mergeCompositeDefaults = env.mergeCompositeDefaults
- local propagateModifiers = env.propagateModifiers
+local BuilderMixin = lib.BuilderMixin
+
+function BuilderMixin:HeightOverrideSlider(sectionPath, spec)
+ spec = spec or {}
+ local childSpec = {
+ path = sectionPath .. ".height",
+ name = spec.name or "Height Override",
+ tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.",
+ min = spec.min or 0,
+ max = spec.max or 40,
+ step = spec.step or 1,
+ getTransform = function(value)
+ return value or 0
+ end,
+ setTransform = function(value)
+ return value > 0 and value or nil
+ end,
+ }
+ self:_propagateModifiers(childSpec, spec)
+ return self:Slider(childSpec)
+end
- function SB.HeightOverrideSlider(sectionPath, spec)
- spec = spec or {}
- local childSpec = {
- path = sectionPath .. ".height",
- name = spec.name or "Height Override",
- tooltip = spec.tooltip or "Override the default bar height. Set to 0 to use the global default.",
- min = spec.min or 0,
- max = spec.max or 40,
- step = spec.step or 1,
- getTransform = function(value)
- return value or 0
- end,
- setTransform = function(value)
- return value > 0 and value or nil
- end,
- }
- propagateModifiers(childSpec, spec)
- return SB.Slider(childSpec)
+--- Font override group.
+--- Optional spec fields:
+--- fontValues function() -> table (choices for the dropdown)
+--- fontFallback function() -> string (fallback font name)
+--- fontSizeFallback function() -> number (fallback font size)
+--- fontTemplate string (custom template for the font picker)
+function BuilderMixin:FontOverrideGroup(sectionPath, spec)
+ spec = self:_mergeCompositeDefaults("FontOverrideGroup", spec)
+ local overridePath = sectionPath .. ".overrideFont"
+
+ local enabledSpec = {
+ path = overridePath,
+ name = spec.enabledName or "Override font",
+ tooltip = spec.enabledTooltip or "Override the global font settings for this module.",
+ getTransform = function(value)
+ return value == true
+ end,
+ }
+ self:_propagateModifiers(enabledSpec, spec)
+ local enabledInit, enabledSetting = self:Checkbox(enabledSpec)
+
+ local outerDisabled = spec.disabled
+ local function isOverrideDisabled()
+ if outerDisabled and outerDisabled() then
+ return true
+ end
+ return not enabledSetting:GetValue()
end
- --- Font override group.
- --- Optional spec fields:
- --- fontValues function() -> table (choices for the dropdown)
- --- fontFallback function() -> string (fallback font name)
- --- fontSizeFallback function() -> number (fallback font size)
- --- fontTemplate string (custom template for the font picker)
- function SB.FontOverrideGroup(sectionPath, spec)
- spec = mergeCompositeDefaults("FontOverrideGroup", spec)
- local overridePath = sectionPath .. ".overrideFont"
-
- local enabledSpec = {
- path = overridePath,
- name = spec.enabledName or "Override font",
- tooltip = spec.enabledTooltip or "Override the global font settings for this module.",
- getTransform = function(value)
- return value == true
- end,
- }
- propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
-
- local outerDisabled = spec.disabled
- local function isOverrideDisabled()
- if outerDisabled and outerDisabled() then
- return true
+ local fontSpec = {
+ path = sectionPath .. ".font",
+ name = spec.fontName or "Font",
+ tooltip = spec.fontTooltip,
+ values = spec.fontValues,
+ disabled = isOverrideDisabled,
+ getTransform = function(value)
+ if value then
+ return value
end
- return not enabledSetting:GetValue()
- end
-
- local fontSpec = {
- path = sectionPath .. ".font",
- name = spec.fontName or "Font",
- tooltip = spec.fontTooltip,
- values = spec.fontValues,
- disabled = isOverrideDisabled,
- getTransform = function(value)
- if value then
- return value
- end
- if spec.fontFallback then
- return spec.fontFallback()
- end
- return nil
- end,
- }
- propagateModifiers(fontSpec, spec)
-
- local fontInit
- if spec.fontTemplate then
- fontSpec.template = spec.fontTemplate
- fontInit = SB.Custom(fontSpec)
- else
- fontInit = SB.Dropdown(fontSpec)
- end
-
- local sizeSpec = {
- path = sectionPath .. ".fontSize",
- name = spec.sizeName or "Font Size",
- tooltip = spec.sizeTooltip,
- min = spec.sizeMin or 6,
- max = spec.sizeMax or 32,
- step = spec.sizeStep or 1,
- disabled = isOverrideDisabled,
- getTransform = function(value)
- if value then
- return value
- end
- if spec.fontSizeFallback then
- return spec.fontSizeFallback()
- end
- return 11
- end,
- }
- propagateModifiers(sizeSpec, spec)
- local sizeInit = SB.Slider(sizeSpec)
-
- return {
- enabledInit = enabledInit,
- enabledSetting = enabledSetting,
- fontInit = fontInit,
- sizeInit = sizeInit,
- }
+ if spec.fontFallback then
+ return spec.fontFallback()
+ end
+ return nil
+ end,
+ }
+ self:_propagateModifiers(fontSpec, spec)
+
+ local fontInit
+ if spec.fontTemplate then
+ fontSpec.template = spec.fontTemplate
+ fontInit = self:Custom(fontSpec)
+ else
+ fontInit = self:Dropdown(fontSpec)
end
- function SB.BorderGroup(borderPath, spec)
- spec = spec or {}
-
- local enabledSpec = {
- path = borderPath .. ".enabled",
- name = spec.enabledName or "Show border",
- tooltip = spec.enabledTooltip,
- }
- propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = SB.Checkbox(enabledSpec)
-
- local thicknessSpec = {
- path = borderPath .. ".thickness",
- name = spec.thicknessName or "Border width",
- tooltip = spec.thicknessTooltip,
- min = spec.thicknessMin or 1,
- max = spec.thicknessMax or 10,
- step = spec.thicknessStep or 1,
- parent = enabledInit,
- parentCheck = function()
- return enabledSetting:GetValue()
- end,
- }
- propagateModifiers(thicknessSpec, spec)
- local thicknessInit = SB.Slider(thicknessSpec)
-
- local colorSpec = {
- path = borderPath .. ".color",
- name = spec.colorName or "Border color",
- tooltip = spec.colorTooltip,
- parent = enabledInit,
- parentCheck = function()
- return enabledSetting:GetValue()
- end,
- }
- propagateModifiers(colorSpec, spec)
- local colorInit = SB.Color(colorSpec)
-
- return {
- enabledInit = enabledInit,
- enabledSetting = enabledSetting,
- thicknessInit = thicknessInit,
- colorInit = colorInit,
- }
- end
+ local sizeSpec = {
+ path = sectionPath .. ".fontSize",
+ name = spec.sizeName or "Font Size",
+ tooltip = spec.sizeTooltip,
+ min = spec.sizeMin or 6,
+ max = spec.sizeMax or 32,
+ step = spec.sizeStep or 1,
+ disabled = isOverrideDisabled,
+ getTransform = function(value)
+ if value then
+ return value
+ end
+ if spec.fontSizeFallback then
+ return spec.fontSizeFallback()
+ end
+ return 11
+ end,
+ }
+ self:_propagateModifiers(sizeSpec, spec)
+ local sizeInit = self:Slider(sizeSpec)
+
+ return {
+ enabledInit = enabledInit,
+ enabledSetting = enabledSetting,
+ fontInit = fontInit,
+ sizeInit = sizeInit,
+ }
+end
- return SB
+function BuilderMixin:BorderGroup(borderPath, spec)
+ spec = spec or {}
+
+ local enabledSpec = {
+ path = borderPath .. ".enabled",
+ name = spec.enabledName or "Show border",
+ tooltip = spec.enabledTooltip,
+ }
+ self:_propagateModifiers(enabledSpec, spec)
+ local enabledInit, enabledSetting = self:Checkbox(enabledSpec)
+
+ local thicknessSpec = {
+ path = borderPath .. ".thickness",
+ name = spec.thicknessName or "Border width",
+ tooltip = spec.thicknessTooltip,
+ min = spec.thicknessMin or 1,
+ max = spec.thicknessMax or 10,
+ step = spec.thicknessStep or 1,
+ parent = enabledInit,
+ parentCheck = function()
+ return enabledSetting:GetValue()
+ end,
+ }
+ self:_propagateModifiers(thicknessSpec, spec)
+ local thicknessInit = self:Slider(thicknessSpec)
+
+ local colorSpec = {
+ path = borderPath .. ".color",
+ name = spec.colorName or "Border color",
+ tooltip = spec.colorTooltip,
+ parent = enabledInit,
+ parentCheck = function()
+ return enabledSetting:GetValue()
+ end,
+ }
+ self:_propagateModifiers(colorSpec, spec)
+ local colorInit = self:Color(colorSpec)
+
+ return {
+ enabledInit = enabledInit,
+ enabledSetting = enabledSetting,
+ thicknessInit = thicknessInit,
+ colorInit = colorInit,
+ }
end
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
index b99258fe..126981e8 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
@@ -8,33 +8,28 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-function lib._installCompositeListControls(SB, env)
- local propagateModifiers = env.propagateModifiers
+local BuilderMixin = lib.BuilderMixin
- local function buildControlList(basePath, defs, spec, factory)
- local results = {}
- spec = spec or {}
- for _, def in ipairs(defs) do
- local childSpec = {
- path = basePath .. "." .. tostring(def.key),
- name = def.name,
- tooltip = def.tooltip,
- }
- propagateModifiers(childSpec, spec)
- local init, setting = factory(childSpec)
- results[#results + 1] = { key = def.key, initializer = init, setting = setting }
- end
-
- return results
- end
-
- function SB.ColorPickerList(basePath, defs, spec)
- return buildControlList(basePath, defs, spec, SB.Color)
+local function buildControlList(builder, basePath, defs, spec, methodName)
+ local results = {}
+ spec = spec or {}
+ for _, def in ipairs(defs) do
+ local childSpec = {
+ path = basePath .. "." .. tostring(def.key),
+ name = def.name,
+ tooltip = def.tooltip,
+ }
+ builder:_propagateModifiers(childSpec, spec)
+ local initializer, setting = builder[methodName](builder, childSpec)
+ results[#results + 1] = { key = def.key, initializer = initializer, setting = setting }
end
+ return results
+end
- function SB.CheckboxList(basePath, defs, spec)
- return buildControlList(basePath, defs, spec, SB.Checkbox)
- end
+function BuilderMixin:ColorPickerList(basePath, defs, spec)
+ return buildControlList(self, basePath, defs, spec, "Color")
+end
- return SB
+function BuilderMixin:CheckboxList(basePath, defs, spec)
+ return buildControlList(self, basePath, defs, spec, "Checkbox")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index b7a0db80..446e0835 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -15,239 +15,230 @@ local getSettingVariable = internal.getSettingVariable
local applyInputRowEnabledState = internal.applyInputRowEnabledState
local applyInputRowFrame = internal.applyInputRowFrame
local cancelInputPreviewTimer = internal.cancelInputPreviewTimer
+local BuilderMixin = lib.BuilderMixin
+
+function BuilderMixin:Checkbox(spec)
+ self:_validateSpecFields("checkbox", spec)
+ local setting, category = self:_makeProxySetting(spec, Settings.VarType.Boolean, false)
+ local initializer = Settings.CreateCheckbox(category, setting, spec.tooltip)
+ self:_applyModifiers(initializer, spec)
+ return initializer, setting
+end
-function lib._installStandardControls(SB, env)
- local applyModifiers = env.applyModifiers
- local colorTableToHex = env.colorTableToHex
- local defaultSliderFormatter = env.defaultSliderFormatter
- local makeProxySetting = env.makeProxySetting
- local makeVarName = env.makeVarName
- local makeVarNameFromIdentifier = env.makeVarNameFromIdentifier
- local postSet = env.postSet
- local registerCategoryRefreshable = env.registerCategoryRefreshable
- local resolveBinding = env.resolveBinding
- local resolveCategory = env.resolveCategory
- local validateSpecFields = env.validateSpecFields
-
- function SB.Checkbox(spec)
- validateSpecFields("checkbox", spec)
- local setting, cat = makeProxySetting(spec, Settings.VarType.Boolean, false)
- local initializer = Settings.CreateCheckbox(cat, setting, spec.tooltip)
- applyModifiers(initializer, spec)
- return initializer, setting
- end
-
- function SB.Slider(spec)
- validateSpecFields("slider", spec)
- local setting, cat = makeProxySetting(spec, Settings.VarType.Number, 0)
+function BuilderMixin:Slider(spec)
+ self:_validateSpecFields("slider", spec)
+ local setting, category = self:_makeProxySetting(spec, Settings.VarType.Number, 0)
- local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1)
- options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or defaultSliderFormatter)
+ local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1)
+ options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or self._defaultSliderFormatter)
- local initializer = Settings.CreateSlider(cat, setting, options, spec.tooltip)
- applyModifiers(initializer, spec)
+ local initializer = Settings.CreateSlider(category, setting, options, spec.tooltip)
+ self:_applyModifiers(initializer, spec)
- return initializer, setting
- end
+ return initializer, setting
+end
- function SB.Dropdown(spec)
- validateSpecFields("dropdown", spec)
- local binding = resolveBinding(spec)
- local cat = resolveCategory(spec)
+function BuilderMixin:Dropdown(spec)
+ self:_validateSpecFields("dropdown", spec)
- local default = binding.default
- if spec.getTransform then
- default = spec.getTransform(default)
- end
+ local binding = self:_resolveBinding(spec)
+ local defaultValue = binding.default
+ if spec.getTransform then
+ defaultValue = spec.getTransform(defaultValue)
+ end
- local varType = spec.varType
- or (type(default) == "number" and Settings.VarType.Number)
- or Settings.VarType.String
-
- local setting = makeProxySetting(spec, varType, "", binding)
- local function optionsGenerator()
- local container = Settings.CreateControlTextContainer()
- local values = type(spec.values) == "function" and spec.values() or spec.values
- if values then
- for _, entry in ipairs(getOrderedValueEntries(values)) do
- container:Add(entry.value, entry.label)
- end
+ local varType = spec.varType
+ or (type(defaultValue) == "number" and Settings.VarType.Number)
+ or Settings.VarType.String
+
+ local setting, category = self:_makeProxySetting(spec, varType, "", binding)
+ local function optionsGenerator()
+ local container = Settings.CreateControlTextContainer()
+ local values = type(spec.values) == "function" and spec.values() or spec.values
+ if values then
+ for _, entry in ipairs(getOrderedValueEntries(values)) do
+ container:Add(entry.value, entry.label)
end
- return container:GetData()
- end
- setting._optionsGen = optionsGenerator
-
- local initializer = Settings.CreateDropdown(cat, setting, optionsGenerator, spec.tooltip)
- if spec.scrollHeight then
- initializer._lsbData = {
- _lsbKind = "scrollDropdown",
- setting = setting,
- values = spec.values,
- scrollHeight = spec.scrollHeight,
- name = spec.name,
- tooltip = spec.tooltip,
- }
- if initializer.SetSetting then
- initializer:SetSetting(setting)
- end
- initializer._lsbRefreshFrame = function(frame)
- if frame and frame.RefreshDropdownText then
- frame:RefreshDropdownText()
- end
- end
- registerCategoryRefreshable(cat, initializer)
end
+ return container:GetData()
+ end
+ setting._optionsGen = optionsGenerator
- if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
+ local initializer = Settings.CreateDropdown(category, setting, optionsGenerator, spec.tooltip)
+ if spec.scrollHeight then
+ initializer._lsbData = {
+ _lsbKind = "scrollDropdown",
+ setting = setting,
+ values = spec.values,
+ scrollHeight = spec.scrollHeight,
+ name = spec.name,
+ tooltip = spec.tooltip,
+ }
+ if initializer.SetSetting then
initializer:SetSetting(setting)
end
- if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame = function(frame)
- if frame and frame.InitDropdown then
- frame:InitDropdown(initializer)
- elseif frame and frame.SetValue and setting.GetValue then
- frame:SetValue(setting:GetValue())
- end
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.RefreshDropdownText then
+ frame:RefreshDropdownText()
end
- registerCategoryRefreshable(cat, initializer)
end
+ self:_registerCategoryRefreshable(category, initializer)
+ end
- if not initializer.GetSetting then
- initializer.GetSetting = function()
- return setting
+ if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
+ initializer:SetSetting(setting)
+ end
+
+ if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame = function(frame)
+ if frame and frame.InitDropdown then
+ frame:InitDropdown(initializer)
+ elseif frame and frame.SetValue and setting.GetValue then
+ frame:SetValue(setting:GetValue())
end
end
-
- applyModifiers(initializer, spec)
-
- return initializer, setting
+ self:_registerCategoryRefreshable(category, initializer)
end
- function SB.Color(spec)
- validateSpecFields("color", spec)
- local variable = makeVarName(spec)
- local cat = resolveCategory(spec)
- local binding = resolveBinding(spec)
-
- local function getter()
- local tbl = binding.get()
- return colorTableToHex(tbl)
+ if not initializer.GetSetting then
+ initializer.GetSetting = function()
+ return setting
end
+ end
- local settingRef
-
- local function setter(hexValue)
- local color = CreateColorFromHexString(hexValue)
- local tbl = { r = color.r, g = color.g, b = color.b, a = color.a }
- binding.set(tbl)
- postSet(spec, tbl, settingRef)
- end
+ self:_applyModifiers(initializer, spec)
- local defaultTbl = binding.default or {}
- local defaultHex = colorTableToHex(defaultTbl)
+ return initializer, setting
+end
- local setting =
- Settings.RegisterProxySetting(cat, variable, Settings.VarType.String, spec.name, defaultHex, getter, setter)
- settingRef = setting
+function BuilderMixin:Color(spec)
+ self:_validateSpecFields("color", spec)
- local initializer = Settings.CreateColorSwatch(cat, setting, spec.tooltip)
- applyModifiers(initializer, spec)
+ local variable = self:_makeVarName(spec)
+ local category = self:_resolveCategory(spec)
+ local binding = self:_resolveBinding(spec)
- return initializer, setting
+ local function getter()
+ return self:_colorTableToHex(binding.get())
end
- function SB.Input(spec)
- validateSpecFields("input", spec)
+ local settingRef
+ local function setter(hexValue)
+ local color = CreateColorFromHexString(hexValue)
+ local value = { r = color.r, g = color.g, b = color.b, a = color.a }
+ binding.set(value)
+ self:_postSet(spec, value, settingRef)
+ end
- local setting, cat = makeProxySetting(spec, Settings.VarType.String, "")
- local data = {
- debounce = spec.debounce,
- maxLetters = spec.maxLetters,
- name = spec.name,
- numeric = spec.numeric,
- onTextChanged = spec.onTextChanged,
- resolveText = spec.resolveText,
- setting = setting,
- settingVariable = getSettingVariable(setting),
- tooltip = spec.tooltip,
- width = spec.width,
- }
+ local defaultHex = self:_colorTableToHex(binding.default or {})
+ local setting = Settings.RegisterProxySetting(
+ category,
+ variable,
+ Settings.VarType.String,
+ spec.name,
+ defaultHex,
+ getter,
+ setter
+ )
+ settingRef = setting
+
+ local initializer = Settings.CreateColorSwatch(category, setting, spec.tooltip)
+ self:_applyModifiers(initializer, spec)
+
+ return initializer, setting
+end
- local watchVariables = {}
- if spec.watch then
- for _, identifier in ipairs(spec.watch) do
- watchVariables[#watchVariables + 1] = makeVarNameFromIdentifier(identifier)
- end
+function BuilderMixin:Input(spec)
+ self:_validateSpecFields("input", spec)
+
+ local setting, category = self:_makeProxySetting(spec, Settings.VarType.String, "")
+ local data = {
+ debounce = spec.debounce,
+ maxLetters = spec.maxLetters,
+ name = spec.name,
+ numeric = spec.numeric,
+ onTextChanged = spec.onTextChanged,
+ resolveText = spec.resolveText,
+ setting = setting,
+ settingVariable = getSettingVariable(setting),
+ tooltip = spec.tooltip,
+ width = spec.width,
+ }
+
+ local watchVariables = {}
+ if spec.watch then
+ for _, identifier in ipairs(spec.watch) do
+ watchVariables[#watchVariables + 1] = self:_makeVarNameFromIdentifier(identifier)
end
- if spec.watchVariables then
- for _, variable in ipairs(spec.watchVariables) do
- watchVariables[#watchVariables + 1] = variable
- end
- end
- if #watchVariables > 0 then
- data.watchVariables = watchVariables
+ end
+ if spec.watchVariables then
+ for _, variable in ipairs(spec.watchVariables) do
+ watchVariables[#watchVariables + 1] = variable
end
+ end
+ if #watchVariables > 0 then
+ data.watchVariables = watchVariables
+ end
- local extent = spec.resolveText and 46 or 26
- local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
- local originalInitFrame = initializer.InitFrame
- local originalResetter = initializer.Resetter
+ local extent = spec.resolveText and 46 or 26
+ local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
+ local originalInitFrame = initializer.InitFrame
+ local originalResetter = initializer.Resetter
- initializer._lsbEnabled = true
- initializer.SetEnabled = function(controlInitializer, enabled)
- controlInitializer._lsbEnabled = enabled
- if controlInitializer._lsbActiveFrame then
- applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled)
- end
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ if controlInitializer._lsbActiveFrame then
+ applyInputRowEnabledState(controlInitializer._lsbActiveFrame, enabled)
end
+ end
- initializer.InitFrame = function(controlInitializer, frame)
- controlInitializer._lsbActiveFrame = frame
- originalInitFrame(controlInitializer, frame)
- applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false)
- end
+ initializer.InitFrame = function(controlInitializer, frame)
+ controlInitializer._lsbActiveFrame = frame
+ originalInitFrame(controlInitializer, frame)
+ applyInputRowEnabledState(frame, controlInitializer._lsbEnabled ~= false)
+ end
- initializer.Resetter = function(controlInitializer, frame)
- cancelInputPreviewTimer(frame)
- if frame and frame._lsbInputEditBox then
- if frame._lsbInputEditBox.ClearFocus then
- frame._lsbInputEditBox:ClearFocus()
- end
- frame._lsbInputEditBox._lsbOwnerFrame = nil
+ initializer.Resetter = function(controlInitializer, frame)
+ cancelInputPreviewTimer(frame)
+ if frame and frame._lsbInputEditBox then
+ if frame._lsbInputEditBox.ClearFocus then
+ frame._lsbInputEditBox:ClearFocus()
end
- frame._lsbInputData = nil
- frame._lsbInputSetting = nil
- if controlInitializer._lsbActiveFrame == frame then
- controlInitializer._lsbActiveFrame = nil
- end
- originalResetter(controlInitializer, frame)
+ frame._lsbInputEditBox._lsbOwnerFrame = nil
end
-
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, spec)
-
- return initializer, setting
+ frame._lsbInputData = nil
+ frame._lsbInputSetting = nil
+ if controlInitializer._lsbActiveFrame == frame then
+ controlInitializer._lsbActiveFrame = nil
+ end
+ originalResetter(controlInitializer, frame)
end
- --- Creates a proxy setting backed by a custom frame template.
- --- The template's Init receives initializer data containing {setting, name, tooltip}.
- function SB.Custom(spec)
- validateSpecFields("custom", spec)
- assert(spec.template, "Custom: spec.template is required")
- local setting, cat = makeProxySetting(spec, spec.varType or Settings.VarType.String, "")
+ Settings.RegisterInitializer(category, initializer)
+ self:_applyModifiers(initializer, spec)
- local initializer =
- Settings.CreateElementInitializer(spec.template, { name = spec.name, tooltip = spec.tooltip })
+ return initializer, setting
+end
- if initializer.SetSetting then
- initializer:SetSetting(setting)
- end
+--- Creates a proxy setting backed by a custom frame template.
+--- The template's Init receives initializer data containing {setting, name, tooltip}.
+function BuilderMixin:Custom(spec)
+ self:_validateSpecFields("custom", spec)
+ assert(spec.template, "Custom: spec.template is required")
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, spec)
+ local setting, category = self:_makeProxySetting(spec, spec.varType or Settings.VarType.String, "")
+ local initializer = Settings.CreateElementInitializer(spec.template, {
+ name = spec.name,
+ tooltip = spec.tooltip,
+ })
- return initializer, setting
+ if initializer.SetSetting then
+ initializer:SetSetting(setting)
end
- return SB
+ Settings.RegisterInitializer(category, initializer)
+ self:_applyModifiers(initializer, spec)
+
+ return initializer, setting
end
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index 45000c4c..2b20979c 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -12,54 +12,47 @@ local internal = lib._internal
local applyCollectionFrame = internal.applyCollectionFrame
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local copyMixin = internal.copyMixin
+local BuilderMixin = lib.BuilderMixin
-function lib._installStandardCollectionControls(SB, env)
- local applyCanvasState = env.applyCanvasState
- local applyModifiers = env.applyModifiers
- local registerCategoryRefreshable = env.registerCategoryRefreshable
- local resolveCategory = env.resolveCategory
+function BuilderMixin:_createCollectionInitializer(spec, errorPrefix)
+ assert(spec.height, errorPrefix .. ": spec.height is required")
- local function createCollectionInitializer(spec, errorPrefix)
- assert(spec.height, errorPrefix .. ": spec.height is required")
- local cat = resolveCategory(spec)
- local data = copyMixin({}, spec)
- if data.variant and data.preset == nil then
- data.preset = data.variant
- end
-
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
+ local category = self:_resolveCategory(spec)
+ local data = copyMixin({}, spec)
+ if data.variant and data.preset == nil then
+ data.preset = data.variant
+ end
- initializer._lsbEnabled = true
- initializer.SetEnabled = function(controlInitializer, enabled)
- controlInitializer._lsbEnabled = enabled
- local activeFrame = controlInitializer._lsbActiveFrame
- if activeFrame then
- applyCanvasState(activeFrame, enabled)
- end
- end
+ local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
- initializer._lsbRefreshFrame = function(frame)
- applyCollectionFrame(frame, data, initializer)
- initializer:SetEnabled(initializer._lsbEnabled ~= false)
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ local activeFrame = controlInitializer._lsbActiveFrame
+ if activeFrame then
+ self:_applyCanvasState(activeFrame, enabled)
end
-
- Settings.RegisterInitializer(cat, initializer)
- registerCategoryRefreshable(cat, initializer)
- applyModifiers(initializer, spec)
-
- return initializer
end
- function SB.List(spec)
- assert(spec.items, "List: spec.items is required")
- assert(not spec.sections, "List: spec.sections is not supported")
- return createCollectionInitializer(spec, "List")
+ initializer._lsbRefreshFrame = function(frame)
+ applyCollectionFrame(frame, data, initializer)
+ initializer:SetEnabled(initializer._lsbEnabled ~= false)
end
- function SB.SectionList(spec)
- assert(spec.sections, "SectionList: spec.sections is required")
- return createCollectionInitializer(spec, "SectionList")
- end
+ Settings.RegisterInitializer(category, initializer)
+ self:_registerCategoryRefreshable(category, initializer)
+ self:_applyModifiers(initializer, spec)
+
+ return initializer
+end
+
+function BuilderMixin:List(spec)
+ assert(spec.items, "List: spec.items is required")
+ assert(not spec.sections, "List: spec.sections is not supported")
+ return self:_createCollectionInitializer(spec, "List")
+end
- return SB
+function BuilderMixin:SectionList(spec)
+ assert(spec.sections, "SectionList: spec.sections is required")
+ return self:_createCollectionInitializer(spec, "SectionList")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 0f6e83d8..16ec310a 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -16,126 +16,125 @@ local applySubheaderFrame = internal.applySubheaderFrame
local copyMixin = internal.copyMixin
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local hideHeaderActionButtons = internal.hideHeaderActionButtons
+local BuilderMixin = lib.BuilderMixin
-function lib._installStandardRowControls(SB, env, config)
- local applyModifiers = env.applyModifiers
- local registerCategoryRefreshable = env.registerCategoryRefreshable
- local resolveCategory = env.resolveCategory
-
- local function addLayoutInitializer(spec, initializer, refreshable)
- local cat = resolveCategory(spec)
- SB._layouts[cat]:AddInitializer(initializer)
- if refreshable then
- registerCategoryRefreshable(cat, initializer)
- end
- applyModifiers(initializer, spec)
- return initializer, cat
+function BuilderMixin:_addLayoutInitializer(spec, initializer, refreshable)
+ local category = self:_resolveCategory(spec)
+ self._layouts[category]:AddInitializer(initializer)
+ if refreshable then
+ self:_registerCategoryRefreshable(category, initializer)
end
+ self:_applyModifiers(initializer, spec)
+ return initializer, category
+end
- function SB.Header(textOrSpec, category)
- local spec
- if type(textOrSpec) == "table" then
- spec = textOrSpec
- else
- spec = {
- name = textOrSpec,
- category = category,
- }
- end
+function BuilderMixin:Header(textOrSpec, category)
+ local spec = type(textOrSpec) == "table" and textOrSpec or {
+ name = textOrSpec,
+ category = category,
+ }
- assert(not spec.actions, "Header: use PageActions for category header buttons")
- local initializer = CreateSettingsListSectionHeaderInitializer(spec.name)
- return addLayoutInitializer(spec, initializer)
- end
+ assert(not spec.actions, "Header: use PageActions for page header buttons")
+ local initializer = CreateSettingsListSectionHeaderInitializer(spec.name)
+ return self:_addLayoutInitializer(spec, initializer)
+end
- function SB.PageActions(spec)
- assert(spec.actions, "PageActions: spec.actions is required")
-
- local cat = resolveCategory(spec)
- local catName = SB._subcategoryNames[cat] or (cat == SB._rootCategory and SB._rootCategoryName) or ""
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
- _lsbKind = "pageActions",
- name = spec.name or catName,
- actions = spec.actions,
- hideTitle = true,
- attachToCategoryHeader = true,
- }, spec.height or 1, applyHeaderFrame)
- initializer._lsbRefreshFrame = function(frame)
- applyHeaderFrame(frame, initializer:GetData())
- end
- initializer._lsbResetFrame = hideHeaderActionButtons
- return addLayoutInitializer(spec, initializer, true)
+function BuilderMixin:PageActions(spec)
+ assert(spec.actions, "PageActions: spec.actions is required")
+
+ local category = self:_resolveCategory(spec)
+ local categoryName = self._subcategoryNames[category]
+ or (category == self._rootCategory and self._rootCategoryName)
+ or ""
+ local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ _lsbKind = "pageActions",
+ name = spec.name or categoryName,
+ actions = spec.actions,
+ hideTitle = true,
+ attachToCategoryHeader = true,
+ }, spec.height or 1, applyHeaderFrame)
+ initializer._lsbRefreshFrame = function(frame)
+ applyHeaderFrame(frame, initializer:GetData())
end
+ initializer._lsbResetFrame = hideHeaderActionButtons
+ return self:_addLayoutInitializer(spec, initializer, true)
+end
- function SB.Subheader(spec)
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
- _lsbKind = "subheader",
- name = spec.name,
- }, 28, applySubheaderFrame)
- return addLayoutInitializer(spec, initializer)
- end
+function BuilderMixin:Subheader(spec)
+ local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ _lsbKind = "subheader",
+ name = spec.name,
+ }, 28, applySubheaderFrame)
+ return self:_addLayoutInitializer(spec, initializer)
+end
- function SB.InfoRow(spec)
- local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, {
- _lsbKind = "infoRow",
- name = spec.name,
- value = spec.value,
- wide = spec.wide,
- multiline = spec.multiline,
- }, spec.height or 26, applyInfoRowFrame)
- initializer._lsbRefreshFrame = function(frame)
- applyInfoRowFrame(frame, initializer:GetData())
- end
- return addLayoutInitializer(spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
+function BuilderMixin:InfoRow(spec)
+ local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, {
+ _lsbKind = "infoRow",
+ name = spec.name,
+ value = spec.value,
+ wide = spec.wide,
+ multiline = spec.multiline,
+ }, spec.height or 26, applyInfoRowFrame)
+ initializer._lsbRefreshFrame = function(frame)
+ applyInfoRowFrame(frame, initializer:GetData())
end
+ return self:_addLayoutInitializer(spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
+end
+
+function BuilderMixin:EmbedCanvas(canvas, height, spec)
+ spec = spec or {}
+
+ local modifiers = copyMixin({}, spec)
+ modifiers.canvas = canvas
- function SB.EmbedCanvas(canvas, height, spec)
- spec = spec or {}
- local cat = spec.category or SB._currentSubcategory or SB._rootCategory
+ local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, {
+ _lsbKind = "embedCanvas",
+ canvas = canvas,
+ }, height or canvas:GetHeight(), applyEmbedCanvasFrame)
- local modifiers = copyMixin({}, spec)
- modifiers.canvas = canvas
+ Settings.RegisterInitializer(self:_resolveCategory(spec), initializer)
+ self:_applyModifiers(initializer, modifiers)
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, {
- _lsbKind = "embedCanvas",
- canvas = canvas,
- }, height or canvas:GetHeight(), applyEmbedCanvasFrame)
+ return initializer
+end
- Settings.RegisterInitializer(cat, initializer)
- applyModifiers(initializer, modifiers)
+function BuilderMixin:_ensureConfirmDialog()
+ if self._confirmDialogName then
+ return self._confirmDialogName
+ end
- return initializer
+ self._confirmDialogName = self._config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm"
+ if not StaticPopupDialogs[self._confirmDialogName] then
+ StaticPopupDialogs[self._confirmDialogName] = {
+ text = "%s",
+ button1 = YES,
+ button2 = NO,
+ OnAccept = function(_, data)
+ if data and data.onAccept then
+ data.onAccept()
+ end
+ end,
+ timeout = 0,
+ whileDead = true,
+ hideOnEscape = true,
+ }
end
- local confirmDialogName = config.varPrefix .. "_" .. MAJOR:gsub("[%-%.]", "_") .. "_SettingsConfirm"
- StaticPopupDialogs[confirmDialogName] = {
- text = "%s",
- button1 = YES,
- button2 = NO,
- OnAccept = function(_, data)
- if data and data.onAccept then
- data.onAccept()
- end
- end,
- timeout = 0,
- whileDead = true,
- hideOnEscape = true,
- }
+ return self._confirmDialogName
+end
- function SB.Button(spec)
- local onClick = spec.onClick
- if spec.confirm then
- local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
- local originalClick = onClick
- onClick = function()
- StaticPopup_Show(confirmDialogName, confirmText, nil, { onAccept = originalClick })
- end
+function BuilderMixin:Button(spec)
+ local onClick = spec.onClick
+ if spec.confirm then
+ local confirmDialogName = self:_ensureConfirmDialog()
+ local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
+ local originalClick = onClick
+ onClick = function()
+ StaticPopup_Show(confirmDialogName, confirmText, nil, { onAccept = originalClick })
end
-
- local initializer =
- CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true)
- return addLayoutInitializer(spec, initializer)
end
- return SB
+ local initializer = CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true)
+ return self:_addLayoutInitializer(spec, initializer)
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 7dd39422..d53a3baf 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -14,8 +14,10 @@ end
lib._loadState = { open = true }
lib._internal = {}
+lib.BuilderMixin = lib.BuilderMixin or {}
local internal = lib._internal
+local BuilderMixin = lib.BuilderMixin
lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
@@ -27,7 +29,7 @@ lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
--- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide
---- callbacks registered via RegisterPage. Defers automatically if
+--- callbacks registered through the root/section/page API. Defers automatically if
--- SettingsPanel has not been created yet (Blizzard_Settings loads on demand).
local function installPageLifecycleHooks()
if lib._pageLifecycleHooked then
@@ -366,6 +368,8 @@ end
lib.CreateHeaderTitle = createHeaderTitle
lib.CreateSubheaderTitle = createSubheaderTitle
+BuilderMixin.CreateHeaderTitle = createHeaderTitle
+BuilderMixin.CreateSubheaderTitle = createSubheaderTitle
--------------------------------------------------------------------------------
-- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
@@ -548,6 +552,13 @@ function lib.CreateColorSwatch(parent)
return swatch
end
+BuilderMixin.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
+BuilderMixin.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
+BuilderMixin.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
+BuilderMixin.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
+BuilderMixin.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
+BuilderMixin.CreateColorSwatch = lib.CreateColorSwatch
+
--------------------------------------------------------------------------------
-- Path accessors: built-in dot-path resolution with numeric key support
--------------------------------------------------------------------------------
@@ -575,19 +586,23 @@ local function defaultSetNestedValue(tbl, path, value)
for segment in path:gmatch("[^.]+") do
if lastKey then
local resolved = lastKey
- if current[lastKey] == nil then
+ local existing = current[lastKey]
+ if existing == nil then
local num = tonumber(lastKey)
if num and current[num] ~= nil then
resolved = num
+ existing = current[num]
end
end
- if current[resolved] == nil then
- current[resolved] = {}
+ if type(existing) ~= "table" then
+ existing = {}
+ current[resolved] = existing
end
- current = current[resolved]
+ current = existing
end
lastKey = segment
end
+ assert(lastKey, "defaultSetNestedValue: path is required")
local resolved = lastKey
if current[lastKey] == nil then
local num = tonumber(lastKey)
@@ -629,422 +644,398 @@ function lib.PathAdapter(config)
}
end
---- Create a new SettingsBuilder instance.
----@param config table
---- Required fields:
---- varPrefix string e.g. "ECM"
---- onChanged function(spec, value) called after each setter
---- Optional fields:
---- pathAdapter table PathAdapter instance for path-based controls
---- compositeDefaults table keyed by composite function name
----@return table builder instance with the full SB API
-function lib:New(config)
- assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required")
- assert(config.onChanged, "LibSettingsBuilder: onChanged is required")
-
- local SB = {}
- SB._rootCategory = nil
- SB._rootCategoryName = nil
- SB._currentSubcategory = nil
- SB._subcategories = {}
- SB._subcategoryNames = {}
- SB._layouts = {}
- SB._reactiveControls = {}
- SB._categoryRefreshables = {}
-
- SB.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
- SB.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
- SB.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
- SB.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
- SB.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
- SB.CreateHeaderTitle = lib.CreateHeaderTitle
- SB.CreateSubheaderTitle = lib.CreateSubheaderTitle
- SB.CreateColorSwatch = lib.CreateColorSwatch
+local function defaultSliderFormatter(value)
+ return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value)
+end
- local function defaultSliderFormatter(value)
- return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value)
- end
+local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" }
+
+local COMMON_SPEC_FIELDS = {
+ path = true,
+ name = true,
+ tooltip = true,
+ category = true,
+ onSet = true,
+ getTransform = true,
+ setTransform = true,
+ parent = true,
+ parentCheck = true,
+ disabled = true,
+ hidden = true,
+ layout = true,
+ type = true,
+ desc = true,
+ get = true,
+ set = true,
+ key = true,
+ default = true,
+}
+
+local EXTRA_FIELDS_BY_TYPE = {
+ checkbox = {},
+ slider = { min = true, max = true, step = true, formatter = true },
+ dropdown = { values = true, scrollHeight = true },
+ color = {},
+ input = {
+ debounce = true,
+ maxLetters = true,
+ numeric = true,
+ onTextChanged = true,
+ resolveText = true,
+ watch = true,
+ watchVariables = true,
+ width = true,
+ },
+ custom = { template = true, varType = true },
+}
+
+function BuilderMixin:_makeVarNameFromIdentifier(identifier)
+ return self._config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
+end
- local adapter = config.pathAdapter
+function BuilderMixin:_makeVarName(spec)
+ local id = spec.key or spec.path
+ return self:_makeVarNameFromIdentifier(id)
+end
- local function makeVarNameFromIdentifier(identifier)
- return config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
- end
+function BuilderMixin:_resolveCategory(spec)
+ return spec.category or self._currentSubcategory or self._rootCategory
+end
- local function makeVarName(spec)
- local id = spec.key or spec.path
- return makeVarNameFromIdentifier(id)
+function BuilderMixin:_registerCategoryRefreshable(category, initializer)
+ if not category or not initializer then
+ return
end
- local function resolveCategory(spec)
- return spec.category or SB._currentSubcategory or SB._rootCategory
+ local refreshables = self._categoryRefreshables[category]
+ if not refreshables then
+ refreshables = {}
+ self._categoryRefreshables[category] = refreshables
end
- local function registerCategoryRefreshable(category, initializer)
- if not category or not initializer then
+ for _, existing in ipairs(refreshables) do
+ if existing == initializer then
return
end
+ end
- local refreshables = SB._categoryRefreshables[category]
- if not refreshables then
- refreshables = {}
- SB._categoryRefreshables[category] = refreshables
- end
-
- for _, existing in ipairs(refreshables) do
- if existing == initializer then
- return
- end
- end
+ refreshables[#refreshables + 1] = initializer
+end
- refreshables[#refreshables + 1] = initializer
+function BuilderMixin:_postSet(spec, value, setting)
+ if spec.onSet then
+ spec.onSet(value, setting, spec._page)
end
+ self._config.onChanged(spec, value)
+ self:_reevaluateReactiveControls()
+end
- local reevaluateReactiveControls
- local setCanvasInteractive
+function BuilderMixin:_resolveBinding(spec)
+ local hasPath = spec.path ~= nil
+ local hasHandler = spec.get ~= nil or spec.set ~= nil
- local function postSet(spec, value, setting)
- if spec.onSet then
- spec.onSet(value, setting)
- end
- config.onChanged(spec, value)
- reevaluateReactiveControls()
+ assert(not (hasPath and hasHandler), "spec cannot have both path and get/set")
+
+ if hasHandler then
+ assert(spec.get, "handler mode requires get")
+ assert(spec.set, "handler mode requires set")
+ assert(spec.key, "handler mode requires key")
+ return { get = spec.get, set = spec.set, default = spec.default }
end
- --- Resolves a spec into a binding with get/set/default.
- --- Handler mode: spec provides explicit get, set, key, and default.
- --- Path mode: spec provides a path string; the pathAdapter generates get/set/default.
- local function resolveBinding(spec)
- local hasPath = spec.path ~= nil
- local hasHandler = spec.get ~= nil or spec.set ~= nil
+ assert(hasPath, "spec must have either path or get/set")
+ assert(self._adapter, "path mode requires a pathAdapter on the builder")
- assert(not (hasPath and hasHandler), "spec cannot have both path and get/set")
+ local binding = self._adapter:resolve(spec.path)
+ if spec.default ~= nil then
+ binding.default = spec.default
+ end
+ return binding
+end
- if hasHandler then
- assert(spec.get, "handler mode requires get")
- assert(spec.set, "handler mode requires set")
- assert(spec.key, "handler mode requires key")
- return { get = spec.get, set = spec.set, default = spec.default }
- end
+function BuilderMixin:_makeProxySetting(spec, varType, defaultFallback, binding)
+ local variable = self:_makeVarName(spec)
+ local category = self:_resolveCategory(spec)
+ local setting
- assert(hasPath, "spec must have either path or get/set")
- assert(adapter, "path mode requires a pathAdapter on the builder")
+ binding = binding or self:_resolveBinding(spec)
- local binding = adapter:resolve(spec.path)
- if spec.default ~= nil then
- binding.default = spec.default
+ local function getter()
+ local value = binding.get()
+ if spec.getTransform then
+ value = spec.getTransform(value)
end
- return binding
+ return value
end
- --- Consolidates the getter/setter/default/transform/register boilerplate
- --- shared by Checkbox, Slider, Dropdown, and Custom.
- local function makeProxySetting(spec, varType, defaultFallback, binding)
- local variable = makeVarName(spec)
- local cat = resolveCategory(spec)
- local setting
+ local function applyValue(value)
+ if spec.setTransform then
+ value = spec.setTransform(value)
+ end
+ binding.set(value)
+ return value
+ end
- binding = binding or resolveBinding(spec)
+ local function setter(value)
+ value = applyValue(value)
+ self:_postSet(spec, value, setting)
+ end
- local function getter()
- local val = binding.get()
- if spec.getTransform then
- val = spec.getTransform(val)
- end
- return val
- end
+ local function setValueNoCallback(_, value)
+ value = applyValue(value)
+ self._config.onChanged(spec, value)
+ self:_reevaluateReactiveControls()
+ end
- local function applyValue(value)
- if spec.setTransform then
- value = spec.setTransform(value)
- end
- binding.set(value)
- return value
- end
+ local defaultValue = binding.default
+ if spec.getTransform then
+ defaultValue = spec.getTransform(defaultValue)
+ end
+ if defaultValue == nil then
+ defaultValue = defaultFallback
+ end
- local function setter(value)
- value = applyValue(value)
- postSet(spec, value, setting)
- end
+ setting = Settings.RegisterProxySetting(category, variable, varType, spec.name, defaultValue, getter, setter)
+ setting.SetValueNoCallback = setValueNoCallback
+ setting._lsbVariable = variable
- local function setValueNoCallback(_, value)
- value = applyValue(value)
- config.onChanged(spec, value)
- reevaluateReactiveControls()
- end
+ return setting, category
+end
- local default = binding.default
- if spec.getTransform then
- default = spec.getTransform(default)
+function BuilderMixin:_propagateModifiers(target, source)
+ for _, key in ipairs(MODIFIER_KEYS) do
+ if target[key] == nil then
+ target[key] = source[key]
end
+ end
+end
- if default == nil then
- default = defaultFallback
- end
+function BuilderMixin:_mergeCompositeDefaults(functionName, spec)
+ local defaults = self._config.compositeDefaults and self._config.compositeDefaults[functionName]
+ if not defaults then
+ return spec or {}
+ end
+ return spec and copyMixin(copyMixin({}, defaults), spec) or copyMixin({}, defaults)
+end
- setting = Settings.RegisterProxySetting(cat, variable, varType, spec.name, default, getter, setter)
- setting.SetValueNoCallback = setValueNoCallback
- setting._lsbVariable = variable
+function BuilderMixin:_validateSpecFields(controlType, spec)
+ if not LSB_DEBUG then
+ return
+ end
- return setting, cat
+ local allowed = EXTRA_FIELDS_BY_TYPE[controlType]
+ if not allowed then
+ return
end
- --- Copies inherited modifier keys from a composite spec onto a child spec
- --- when the child hasn't set them explicitly.
- local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" }
- local function propagateModifiers(target, source)
- for _, key in ipairs(MODIFIER_KEYS) do
- if target[key] == nil then
- target[key] = source[key]
- end
+ for key in pairs(spec) do
+ if not COMMON_SPEC_FIELDS[key] and not allowed[key] then
+ print(
+ "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '"
+ .. tostring(key)
+ .. "' on "
+ .. controlType
+ .. " control '"
+ .. tostring(spec.name or spec.path)
+ .. "'"
+ )
end
end
+end
- --- Merges compositeDefaults for the given composite function name onto spec.
- --- Spec values win over defaults.
- local function mergeCompositeDefaults(functionName, spec)
- local defaults = config.compositeDefaults and config.compositeDefaults[functionName]
- if not defaults then
- return spec or {}
+function BuilderMixin:_setCanvasInteractive(frame, enabled)
+ if frame.SetEnabled then
+ frame:SetEnabled(enabled)
+ end
+ if frame.EnableMouse then
+ frame:EnableMouse(enabled)
+ end
+ if frame.GetChildren then
+ local children = { frame:GetChildren() }
+ for i = 1, #children do
+ self:_setCanvasInteractive(children[i], enabled)
end
- return spec and copyMixin(copyMixin({}, defaults), spec) or copyMixin({}, defaults)
- end
-
- ----------------------------------------------------------------------------
- -- Debug spec validation (active only when LSB_DEBUG is truthy)
- ----------------------------------------------------------------------------
-
- local COMMON_SPEC_FIELDS = {
- path = true,
- name = true,
- tooltip = true,
- category = true,
- onSet = true,
- getTransform = true,
- setTransform = true,
- parent = true,
- parentCheck = true,
- disabled = true,
- hidden = true,
- layout = true,
- type = true,
- desc = true,
- get = true,
- set = true,
- key = true,
- default = true,
- }
+ end
+end
- local EXTRA_FIELDS_BY_TYPE = {
- checkbox = {},
- slider = { min = true, max = true, step = true, formatter = true },
- dropdown = { values = true, scrollHeight = true },
- color = {},
- input = {
- debounce = true,
- maxLetters = true,
- numeric = true,
- onTextChanged = true,
- resolveText = true,
- watch = true,
- watchVariables = true,
- width = true,
- },
- custom = { template = true, varType = true },
- }
+function BuilderMixin:_isParentEnabled(spec)
+ if not spec.parent then
+ return true
+ end
+ if spec.parentCheck then
+ return spec.parentCheck()
+ end
+ if not spec.parent.GetSetting then
+ return true
+ end
- local function validateSpecFields(controlType, spec)
- if not LSB_DEBUG then
- return
- end
- local allowed = EXTRA_FIELDS_BY_TYPE[controlType]
- if not allowed then
- return
- end
- for key in pairs(spec) do
- if not COMMON_SPEC_FIELDS[key] and not allowed[key] then
- print(
- "|cffFF8800LibSettingsBuilder WARNING:|r Unknown spec field '"
- .. tostring(key)
- .. "' on "
- .. controlType
- .. " control '"
- .. tostring(spec.name or spec.path)
- .. "'"
- )
- end
- end
+ local setting = spec.parent:GetSetting()
+ if not setting then
+ return true
end
- setCanvasInteractive = function(frame, enabled)
- if frame.SetEnabled then
- frame:SetEnabled(enabled)
- end
- if frame.EnableMouse then
- frame:EnableMouse(enabled)
- end
- if frame.GetChildren then
- local children = { frame:GetChildren() }
- for i = 1, #children do
- setCanvasInteractive(children[i], enabled)
- end
- end
+ return setting:GetValue()
+end
+
+function BuilderMixin:_isControlEnabled(spec)
+ if spec.disabled and spec.disabled() then
+ return false
end
+ return self:_isParentEnabled(spec)
+end
- local function isParentEnabled(spec)
- if not spec.parent then
- return true
- end
+function BuilderMixin:_applyCanvasState(canvas, enabled)
+ if canvas.SetAlpha then
+ canvas:SetAlpha(enabled and 1 or 0.5)
+ end
+ self:_setCanvasInteractive(canvas, enabled)
+end
- if spec.parentCheck then
- return spec.parentCheck()
+function BuilderMixin:_reevaluateReactiveControls()
+ local panel = SettingsPanel
+ if panel and panel:IsShown() then
+ local settingsList = panel:GetSettingsList()
+ if settingsList and settingsList.ScrollBox then
+ settingsList.ScrollBox:ForEachFrame(function(frame)
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ end)
end
+ end
- if not spec.parent.GetSetting then
- return true
+ for _, entry in ipairs(self._reactiveControls) do
+ local spec = entry[2]
+ if spec.canvas then
+ self:_applyCanvasState(spec.canvas, self:_isControlEnabled(spec))
end
+ end
+end
- local setting = spec.parent:GetSetting()
- if not setting then
- return true
- end
+function BuilderMixin:_applyEnabledState(initializer, spec)
+ local enabled = self:_isControlEnabled(spec)
+ if initializer.SetEnabled then
+ initializer:SetEnabled(enabled)
+ end
+ if spec.canvas then
+ self:_applyCanvasState(spec.canvas, enabled)
+ end
+ return enabled
+end
- return setting:GetValue()
+function BuilderMixin:_applyModifiers(initializer, spec)
+ if not initializer then
+ return
end
- local function isControlEnabled(spec)
- if spec.disabled and spec.disabled() then
- return false
- end
- return isParentEnabled(spec)
+ if spec.disabled or spec.canvas or spec.parent then
+ initializer:AddModifyPredicate(function()
+ return self:_applyEnabledState(initializer, spec)
+ end)
+ self:_applyEnabledState(initializer, spec)
end
- local function applyCanvasState(canvas, enabled)
- if canvas.SetAlpha then
- canvas:SetAlpha(enabled and 1 or 0.5)
- end
- setCanvasInteractive(canvas, enabled)
- end
-
- reevaluateReactiveControls = function()
- local panel = SettingsPanel
- if panel and panel:IsShown() then
- local settingsList = panel:GetSettingsList()
- if settingsList and settingsList.ScrollBox then
- settingsList.ScrollBox:ForEachFrame(function(frame)
- if frame.EvaluateState then
- frame:EvaluateState()
- end
- end)
- end
- end
+ if spec.parent then
+ initializer:SetParentInitializer(spec.parent, function()
+ return self:_isParentEnabled(spec)
+ end)
+ end
- for _, entry in ipairs(SB._reactiveControls) do
- local spec = entry[2]
- if spec.canvas then
- applyCanvasState(spec.canvas, isControlEnabled(spec))
- end
- end
+ if spec.hidden then
+ initializer:AddShownPredicate(function()
+ return not spec.hidden()
+ end)
end
- local function applyEnabledState(initializer, spec)
- local enabled = isControlEnabled(spec)
- if initializer.SetEnabled then
- initializer:SetEnabled(enabled)
- end
- if spec.canvas then
- applyCanvasState(spec.canvas, enabled)
- end
- return enabled
+ if spec.canvas then
+ self._reactiveControls[#self._reactiveControls + 1] = { initializer, spec }
end
+end
- local function applyModifiers(initializer, spec)
- if not initializer then
- return
- end
+function BuilderMixin:_colorTableToHex(tbl)
+ if not tbl then
+ return "FFFFFFFF"
+ end
+ return string.format(
+ "%02X%02X%02X%02X",
+ math.floor((tbl.a or 1) * 255 + 0.5),
+ math.floor((tbl.r or 1) * 255 + 0.5),
+ math.floor((tbl.g or 1) * 255 + 0.5),
+ math.floor((tbl.b or 1) * 255 + 0.5)
+ )
+end
- if spec.disabled or spec.canvas or spec.parent then
- initializer:AddModifyPredicate(function()
- return applyEnabledState(initializer, spec)
- end)
- applyEnabledState(initializer, spec)
- end
+function BuilderMixin:_storeCategory(name, category, layout)
+ self._subcategories[name] = category
+ self._subcategoryNames[category] = name
+ self._layouts[category] = layout
+ return category
+end
- if spec.parent then
- initializer:SetParentInitializer(spec.parent, function()
- return isParentEnabled(spec)
- end)
- end
+BuilderMixin._defaultSliderFormatter = defaultSliderFormatter
- if spec.hidden then
- initializer:AddShownPredicate(function()
- return not spec.hidden()
- end)
- end
+--- Create a new SettingsBuilder instance.
+---@param config table
+--- Required fields:
+--- varPrefix string e.g. "ECM"
+--- onChanged function(spec, value) called after each setter
+--- Optional fields:
+--- name string root category display name for declarative registration
+--- pathAdapter table PathAdapter instance for path-based controls
+--- compositeDefaults table keyed by composite function name
+---@return table builder instance with the full SB API
+function lib:New(config)
+ assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required")
+ assert(config.onChanged, "LibSettingsBuilder: onChanged is required")
- if spec.canvas then
- SB._reactiveControls[#SB._reactiveControls + 1] = { initializer, spec }
- end
- end
+ local SB
+ SB = setmetatable({
+ _config = config,
+ _adapter = config.pathAdapter,
+ _boundMethods = {},
+ _rootCategory = nil,
+ _rootCategoryName = nil,
+ _rootRegistered = nil,
+ _registeredRootPage = nil,
+ _currentSubcategory = nil,
+ _subcategories = {},
+ _subcategoryNames = {},
+ _layouts = {},
+ _reactiveControls = {},
+ _categoryRefreshables = {},
+ _pages = {},
+ _pageList = {},
+ _sectionList = {},
+ _sections = {},
+ _nextRootPageSequence = 0,
+ _nextSectionSequence = 0,
+ name = nil,
+ }, {
+ __index = function(_, key)
+ local value = BuilderMixin[key]
+ if type(value) ~= "function" then
+ return value
+ end
- local function colorTableToHex(tbl)
- if not tbl then
- return "FFFFFFFF"
- end
- return string.format(
- "%02X%02X%02X%02X",
- math.floor((tbl.a or 1) * 255 + 0.5),
- math.floor((tbl.r or 1) * 255 + 0.5),
- math.floor((tbl.g or 1) * 255 + 0.5),
- math.floor((tbl.b or 1) * 255 + 0.5)
- )
- end
+ local bound = SB._boundMethods[key]
+ if bound then
+ return bound
+ end
- local function storeCategory(name, category, layout)
- SB._subcategories[name], SB._subcategoryNames[category], SB._layouts[category] = category, name, layout
- return category
- end
-
- local env = {
- applyCanvasState = applyCanvasState,
- applyModifiers = applyModifiers,
- colorTableToHex = colorTableToHex,
- defaultSliderFormatter = defaultSliderFormatter,
- getCanvasLayoutMetrics = getCanvasLayoutMetrics,
- makeProxySetting = makeProxySetting,
- makeVarName = makeVarName,
- makeVarNameFromIdentifier = makeVarNameFromIdentifier,
- mergeCompositeDefaults = mergeCompositeDefaults,
- postSet = postSet,
- propagateModifiers = propagateModifiers,
- registerCategoryRefreshable = registerCategoryRefreshable,
- resolveBinding = resolveBinding,
- resolveCategory = resolveCategory,
- storeCategory = storeCategory,
- validateSpecFields = validateSpecFields,
- }
+ bound = function(first, ...)
+ if first == nil or first == SB then
+ return value(SB, ...)
+ end
+ return value(SB, first, ...)
+ end
+ SB._boundMethods[key] = bound
+ return bound
+ end,
+ })
- assert(type(lib._installPrimitiveLayout) == "function", "LibSettingsBuilder primitive layout module not loaded")
- assert(type(lib._installStandardControls) == "function", "LibSettingsBuilder controls module not loaded")
- assert(
- type(lib._installStandardCollectionControls) == "function",
- "LibSettingsBuilder collection controls module not loaded"
- )
- assert(type(lib._installStandardRowControls) == "function", "LibSettingsBuilder row controls module not loaded")
- assert(type(lib._installCompositeGroups) == "function", "LibSettingsBuilder composite group module not loaded")
- assert(
- type(lib._installCompositeListControls) == "function",
- "LibSettingsBuilder composite list module not loaded"
- )
- assert(type(lib._installUtility) == "function", "LibSettingsBuilder utility module not loaded")
-
- lib._installPrimitiveLayout(SB, env)
- lib._installStandardControls(SB, env)
- lib._installStandardCollectionControls(SB, env)
- lib._installStandardRowControls(SB, env, config)
- lib._installCompositeGroups(SB, env)
- lib._installCompositeListControls(SB, env)
- lib._installUtility(SB, env)
+ if config.name ~= nil then
+ SB:_initializeRoot(config.name)
+ end
return SB
end
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index 95a3e803..72af711d 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -10,50 +10,45 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
+local BuilderMixin = lib.BuilderMixin
+
+function BuilderMixin:_createRootCategory(name)
+ local category, layout = Settings.RegisterVerticalLayoutCategory(name)
+ self._rootCategory = category
+ self._rootCategoryName = name
+ self._layouts[category] = layout
+ self._currentSubcategory = nil
+ return category
+end
-function lib._installPrimitiveLayout(SB, env)
- local storeCategory = env.storeCategory
-
- function SB.CreateRootCategory(name)
- local category, layout = Settings.RegisterVerticalLayoutCategory(name)
- SB._rootCategory = category
- SB._rootCategoryName = name
- SB._layouts[category] = layout
- SB._currentSubcategory = nil
- return category
- end
-
- function SB.CreateSubcategory(name, parentCategory)
- local parent = parentCategory or SB._rootCategory
- local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
- SB._currentSubcategory = storeCategory(name, subcategory, layout)
- return subcategory
- end
-
- function SB.CreateCanvasSubcategory(frame, name, parentCategory)
- local parent = parentCategory or SB._rootCategory
- local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
- return storeCategory(name, subcategory, layout)
- end
+function BuilderMixin:_createSubcategory(name, parentCategory)
+ local parent = parentCategory or self._rootCategory
+ local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
+ self._currentSubcategory = self:_storeCategory(name, subcategory, layout)
+ return subcategory
+end
- --- Creates a canvas subcategory with a CanvasLayout engine attached.
- --- Returns a layout object with AddHeader, AddDescription, AddSlider,
- --- AddColorSwatch, AddButton, AddScrollList methods that position
- --- controls to match Blizzard's vertical-layout settings pages.
- ---@param name string Subcategory display name.
- ---@param parentCategory? table Parent category (defaults to root).
- ---@return table layout CanvasLayout instance (layout.frame for the raw frame).
- function SB.CreateCanvasLayout(name, parentCategory)
- local frame = CreateFrame("Frame", nil)
- SB.CreateCanvasSubcategory(frame, name, parentCategory)
- local metrics = copyMixin({}, lib.CanvasLayoutDefaults)
- return setmetatable({
- frame = frame,
- yPos = 0,
- elements = {},
- _metrics = metrics,
- }, { __index = lib.CanvasLayout })
- end
+function BuilderMixin:_createCanvasSubcategory(frame, name, parentCategory)
+ local parent = parentCategory or self._rootCategory
+ local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
+ return self:_storeCategory(name, subcategory, layout)
+end
- return SB
+--- Creates a canvas subcategory with a CanvasLayout engine attached.
+--- Returns a layout object with AddHeader, AddDescription, AddSlider,
+--- AddColorSwatch, AddButton, AddScrollList methods that position
+--- controls to match Blizzard's vertical-layout settings pages.
+---@param name string Subcategory display name.
+---@param parentCategory? table Parent category (defaults to root).
+---@return table layout CanvasLayout instance (layout.frame for the raw frame).
+function BuilderMixin:CreateCanvasLayout(name, parentCategory)
+ local frame = CreateFrame("Frame", nil)
+ self:_createCanvasSubcategory(frame, name, parentCategory)
+ local metrics = copyMixin({}, lib.CanvasLayoutDefaults)
+ return setmetatable({
+ frame = frame,
+ yPos = 0,
+ elements = {},
+ _metrics = metrics,
+ }, { __index = lib.CanvasLayout })
end
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index e375e61c..7be7b5fa 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -11,7 +11,7 @@ It supports:
- composite builders for common settings groups,
- canonical row types for headers, subheaders, info rows, buttons, canvases, and page actions,
- XML/template-backed custom controls when a built-in row is not enough,
-- category refresh hooks for out-of-band state changes,
+- page-owned refresh hooks for out-of-band state changes,
- deterministic dropdown ordering,
- clickable slider value editing.
@@ -21,15 +21,16 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
| Need | LibSettingsBuilder |
|---|---|
-| Standard settings pages | `RegisterPage(...)` |
-| Fine-grained control | imperative `SB.Checkbox(...)`, `SB.Slider(...)`, `SB.Input(...)`, etc. |
+| Standard settings pages | `SB.GetRoot(name)` → `root:Register({ page = ..., sections = { ... } })` |
+| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec |
+| Dynamic refresh | `onRegistered(page)` + `page:Refresh()` |
| Existing AceDB profiles | `PathAdapter(...)` |
| Custom storage | handler mode with `get` / `set` / `key` |
| Text entry / numeric ID fields | `SB.Input(...)` or `type = "input"` |
| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
| Reusable settings groups | border, font override, positioning composites |
| XML-backed bespoke widgets | `SB.Custom(...)` |
-| Force visible rows to refresh | `SB.RefreshCategory(...)` |
+| Force visible rows to refresh | `page:Refresh()` |
## Quick start
@@ -51,34 +52,47 @@ local SB = LSB:New({
end,
})
-SB.CreateRootCategory("My Addon")
-
-SB.RegisterPage({
- name = "General",
- path = "general",
- rows = {
- {
- type = "checkbox",
- path = "enabled",
- name = "Enable",
+local root = SB.GetRoot("My Addon")
+
+root:Register({
+ page = {
+ key = "about",
+ rows = {
+ {
+ type = "info",
+ name = "Version",
+ value = "1.0.0",
+ },
},
+ },
+ sections = {
{
- type = "slider",
- path = "opacity",
- name = "Opacity",
- min = 0,
- max = 100,
- step = 1,
+ key = "general",
+ name = "General",
+ path = "general",
+ rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = "Enable",
+ },
+ {
+ type = "slider",
+ path = "opacity",
+ name = "Opacity",
+ min = 0,
+ max = 100,
+ step = 1,
+ },
+ },
},
},
})
-
-SB.RegisterCategories()
```
## Canonical row types
-The page API accepts canonical row types only.
+Declarative pages accept canonical row types only.
| Type | Meaning |
|---|---|
@@ -92,7 +106,7 @@ The page API accepts canonical row types only.
| `subheader` | Secondary text row |
| `info` | Left-label / right-value informational row |
| `canvas` | Embedded frame row for canvas content |
-| `pageActions` | Right-aligned category-header action row |
+| `pageActions` | Right-aligned page-header action row |
| `list` | First-class dynamic flat list widget |
| `sectionList` | First-class dynamic grouped list widget |
| `custom` | Proxy setting backed by a custom XML template |
@@ -148,6 +162,8 @@ The library has three main implementation paths:
- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `pageActions` are initializer/layout helpers rather than persisted settings.
- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `checkboxList` expand into multiple child controls.
+The recommended author-facing registration model is declarative: get the singleton root once, export plain page/section spec tables, and call `root:Register(...)` with the assembled tree. Deprecated non-declarative page-construction APIs have been removed.
+
Use `pageActions` for right-aligned page buttons. Use `list` and `sectionList` for dynamic editors, and keep `canvas` / `custom` as escape hatches for truly bespoke frames. Canvas rows stay on the existing lifecycle path, so page switches continue to reuse the same proven frame handling.
`input` specifically is implemented as a built-in custom list row using `SettingsListElementTemplate`, with an `InputBoxTemplate` edit box anchored in the standard left-label / right-control layout. It does **not** need a separate XML template the way `custom` controls do.
@@ -185,8 +201,8 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr
- Embed the library inside your addon's `Libs/` folder.
- Load `LibStub` before `LibSettingsBuilder`.
- Load `Libs\LibSettingsBuilder\embed.xml` rather than the individual library Lua files.
-- Prefer `RegisterPage(...)` for declarative pages and the imperative builders for fine-grained control.
-- `SB.RefreshCategory(...)` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes.
+- Prefer a single `root:Register({ page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls.
+- `page:Refresh()` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes.
- Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks.
## License
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index 54d2be6c..e76a4d2f 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -37,10 +37,16 @@ describe("LibSettingsBuilder", function()
varPrefix = varPrefix,
onChanged = function() end,
})
- SB2.CreateRootCategory(categoryName or "Test")
+ SB2.GetRoot(categoryName or "Test")
return SB2
end
+ local function setCurrentCategoryFromSection(sb, sectionSpec, rootName)
+ local root, _, page = TestHelpers.RegisterSectionSpec(sb, sectionSpec, rootName)
+ sb._currentSubcategory = page and page._category or nil
+ return root, page
+ end
+
local function createSettingsPanelMock()
local frames = {}
local hookScripts = {}
@@ -250,7 +256,7 @@ describe("LibSettingsBuilder", function()
TestHelpers.LoadLibSettingsBuilder()
-- Register LSMW stub
- local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1)
+ local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1) or LibStub("LibLSMSettingsWidgets-1.0")
lsmw.GetFontValues = function()
return { Expressway = "Expressway" }
end
@@ -299,6 +305,14 @@ describe("LibSettingsBuilder", function()
ANCHORMODE_FREE = 2,
DEFAULT_BAR_WIDTH = 300,
},
+ L = setmetatable({}, { __index = function(_, key)
+ return key
+ end }),
+ ColorUtil = {
+ Sparkle = function(text)
+ return text
+ end,
+ },
CloneValue = TestHelpers.deepClone,
Runtime = {
ScheduleLayoutUpdate = function()
@@ -311,21 +325,31 @@ describe("LibSettingsBuilder", function()
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, addonNS)
SB = addonNS.SettingsBuilder
- SB.CreateRootCategory("TestAddon")
- SB.CreateSubcategory("TestSection")
+ setCurrentCategoryFromSection(SB, {
+ key = "testSection",
+ name = "TestSection",
+ rows = {},
+ }, "TestAddon")
end)
-- Category lifecycle
- it("CreateRootCategory, CreateSubcategory, GetRootCategoryID, GetSubcategoryID", function()
- assert.are.equal("TestAddon", SB.GetRootCategoryID())
- assert.is_not_nil(SB.GetSubcategoryID("TestSection"))
- assert.is_nil(SB.GetSubcategoryID("MissingSection"))
+ it("GetRoot exposes registered sections and owned categories", function()
+ local root = SB.GetRoot("TestAddon")
+ local section = root:GetSection("testSection")
+ local page = assert(section and section:GetPage("main"))
+
+ assert.are.equal("TestAddon", root.name)
+ assert.is_not_nil(section)
+ assert.is_nil(root:GetSection("missingSection"))
+ assert.is_true(root:HasCategory(page._category))
+ assert.is_false(root:HasCategory({}))
end)
- it("RegisterCategories does not error", function()
- assert.has_no.errors(function()
- SB.RegisterCategories()
- end)
+ it("GetRoot reuses the singleton root handle", function()
+ local rootA = SB.GetRoot("TestAddon")
+ local rootB = SB.GetRoot("TestAddon")
+
+ assert.are.equal(rootA, rootB)
end)
it("Setting current subcategory to root allows adding headers there", function()
@@ -1268,18 +1292,15 @@ describe("LibSettingsBuilder", function()
assert.is_not_nil(results[2].setting)
end)
- -- RegisterSection
- it("RegisterSection stores section in namespace", function()
- local ns = {}
- local section = { RegisterSettings = function() end }
- SB.RegisterSection(ns, "Foo", section)
- assert.are.same(section, ns.OptionsSections.Foo)
- end)
-
-- Built-in path accessors
it("path accessors read and write nested values", function()
local SB2 = createSB2("TEST2", "Test2")
- SB2.CreateSubcategory("Sub2")
+ local _, page = setCurrentCategoryFromSection(SB2, {
+ key = "sub2",
+ name = "Sub2",
+ rows = {},
+ }, "Test2")
+ SB2._currentSubcategory = page._category
local _, setting = SB2.Checkbox({
path = "global.hideWhenMounted",
@@ -1296,7 +1317,12 @@ describe("LibSettingsBuilder", function()
addonNS.Addon.db.defaults.profile.powerBar.colors[0] = { r = 0, g = 0, b = 1, a = 1 }
local SB2 = createSB2("TEST3", "Test3")
- SB2.CreateSubcategory("Sub3")
+ local _, page = setCurrentCategoryFromSection(SB2, {
+ key = "sub3",
+ name = "Sub3",
+ rows = {},
+ }, "Test3")
+ SB2._currentSubcategory = page._category
local _, setting = SB2.Color({
path = "powerBar.colors.0",
@@ -1315,7 +1341,12 @@ describe("LibSettingsBuilder", function()
end)
it("Header matching subcategory name still returns a normal header", function()
- SB.CreateSubcategory("Appearance")
+ local _, page = setCurrentCategoryFromSection(SB, {
+ key = "appearance",
+ name = "Appearance",
+ rows = {},
+ }, "TestAddon")
+ SB._currentSubcategory = page._category
local init = SB.Header("Appearance")
assert.are.equal("header", init._type)
assert.are.equal("Appearance", init._text)
@@ -1535,90 +1566,110 @@ describe("LibSettingsBuilder", function()
})
end)
- -- RegisterPage
- it("RegisterPage creates subcategory and controls from ordered rows", function()
+ -- Declarative root registration
+ it("root:Register creates section pages and controls from ordered rows", function()
local SB2 = createSB2("TBL1", "TableTest")
+ local root = SB2.GetRoot("TableTest")
- SB2.RegisterPage({
- name = "Test Section",
- path = "global",
- rows = {
- { id = "header1", type = "header", name = "Visibility" },
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
- { id = "val", type = "slider", path = "value", name = "Value", min = 0, max = 10, step = 1 },
- { id = "mode", type = "dropdown", path = "mode", name = "Mode", values = { solid = "Solid" } },
+ root:Register({
+ sections = {
+ {
+ key = "testSection",
+ name = "Test Section",
+ path = "global",
+ rows = {
+ { id = "header1", type = "header", name = "Visibility" },
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
+ { id = "val", type = "slider", path = "value", name = "Value", min = 0, max = 10, step = 1 },
+ { id = "mode", type = "dropdown", path = "mode", name = "Mode", values = { solid = "Solid" } },
+ },
+ },
},
})
- -- Verify subcategory was created
- assert.is_not_nil(SB2.GetSubcategoryID("Test Section"))
+ local page = root:GetSection("testSection"):GetPage("main")
+ assert.is_not_nil(page)
+ assert.is_true(root:HasCategory(page._category))
end)
- it("RegisterPage inherits disabled from the page", function()
+ it("root:Register inherits disabled from the page spec", function()
local disabledFn = function()
return true
end
local SB2 = createSB2("TBL2", "InheritTest")
- SB2.RegisterPage({
- name = "Inherit Section",
- path = "global",
- disabled = disabledFn,
- rows = {
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
- },
- })
-
- -- The control should have the disabled predicate applied
- -- (We can't directly inspect the predicate, but we verify no error occurs)
- assert.is_not_nil(SB2.GetSubcategoryID("Inherit Section"))
+ assert.has_no.errors(function()
+ SB2.GetRoot("InheritTest"):Register({
+ sections = {
+ {
+ key = "inheritSection",
+ name = "Inherit Section",
+ path = "global",
+ disabled = disabledFn,
+ rows = {
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
+ },
+ },
+ },
+ })
+ end)
end)
- it("RegisterPage resolves parent references by row id", function()
+ it("root:Register resolves parent references by row id", function()
local SB2 = createSB2("TBL3", "ParentRefTest")
assert.has_no.errors(function()
- SB2.RegisterPage({
- name = "Parent Ref Section",
- path = "global",
- rows = {
- { id = "parentCtrl", type = "checkbox", path = "hideWhenMounted", name = "Parent" },
+ SB2.GetRoot("ParentRefTest"):Register({
+ sections = {
{
- id = "childCtrl",
- type = "slider",
- path = "value",
- name = "Child",
- min = 0,
- max = 10,
- step = 1,
- parent = "parentCtrl",
- parentCheck = "checked",
+ key = "parentRefSection",
+ name = "Parent Ref Section",
+ path = "global",
+ rows = {
+ { id = "parentCtrl", type = "checkbox", path = "hideWhenMounted", name = "Parent" },
+ {
+ id = "childCtrl",
+ type = "slider",
+ path = "value",
+ name = "Child",
+ min = 0,
+ max = 10,
+ step = 1,
+ parent = "parentCtrl",
+ parentCheck = "checked",
+ },
+ },
},
},
})
end)
end)
- it("RegisterPage accepts canonical row types only", function()
+ it("root:Register accepts canonical row types only", function()
local SB2 = createSB2("TBL4", "AliasTest")
assert.has_no.errors(function()
- SB2.RegisterPage({
- name = "Alias Section",
- path = "global",
- rows = {
- { id = "t", type = "checkbox", path = "hideWhenMounted", name = "Toggle" },
- { id = "r", type = "slider", path = "value", name = "Range", min = 0, max = 10, step = 1 },
- { id = "s", type = "dropdown", path = "mode", name = "Select", values = { solid = "Solid" } },
- { id = "h", type = "header", name = "Header" },
- { id = "d", type = "subheader", name = "Desc" },
- { id = "i", type = "info", name = "Author", value = "Test" },
+ SB2.GetRoot("AliasTest"):Register({
+ sections = {
+ {
+ key = "aliasSection",
+ name = "Alias Section",
+ path = "global",
+ rows = {
+ { id = "t", type = "checkbox", path = "hideWhenMounted", name = "Toggle" },
+ { id = "r", type = "slider", path = "value", name = "Range", min = 0, max = 10, step = 1 },
+ { id = "s", type = "dropdown", path = "mode", name = "Select", values = { solid = "Solid" } },
+ { id = "h", type = "header", name = "Header" },
+ { id = "d", type = "subheader", name = "Desc" },
+ { id = "i", type = "info", name = "Author", value = "Test" },
+ },
+ },
},
})
end)
end)
- it("RegisterPage supports desc as alias for tooltip", function()
+ it("root:Register supports desc as alias for tooltip", function()
local capturedTooltip
local settings = Settings
local origCreateCheckbox = settings.CreateCheckbox
@@ -1629,16 +1680,21 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("TBL5", "DescTest")
- SB2.RegisterPage({
- name = "Desc Section",
- path = "global",
- rows = {
+ SB2.GetRoot("DescTest"):Register({
+ sections = {
{
- id = "mounted",
- type = "checkbox",
- path = "hideWhenMounted",
- name = "Hide",
- desc = "Hide when on a mount.",
+ key = "descSection",
+ name = "Desc Section",
+ path = "global",
+ rows = {
+ {
+ id = "mounted",
+ type = "checkbox",
+ path = "hideWhenMounted",
+ name = "Hide",
+ desc = "Hide when on a mount.",
+ },
+ },
},
},
})
@@ -1647,23 +1703,26 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Hide when on a mount.", capturedTooltip)
end)
- it("RegisterPage path prefixing works", function()
+ it("root:Register applies section path prefixing", function()
local SB2 = createSB2("TBL7", "PrefixTest")
- SB2.RegisterPage({
- name = "Prefix Section",
- path = "powerBar",
- rows = {
- { id = "enabled", type = "checkbox", path = "enabled", name = "Enabled" },
+ SB2.GetRoot("PrefixTest"):Register({
+ sections = {
+ {
+ key = "prefixSection",
+ name = "Prefix Section",
+ path = "powerBar",
+ rows = {
+ { id = "enabled", type = "checkbox", path = "enabled", name = "Enabled" },
+ },
+ },
},
})
- -- The checkbox should read from powerBar.enabled
assert.is_true(addonNS.Addon.db.profile.powerBar.enabled)
end)
- -- RegisterPage condition support
- it("RegisterPage condition=false skips entry", function()
+ it("root:Register condition=false skips entry", function()
local headerCreated = false
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1675,19 +1734,24 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND1", "CondTest")
- SB2.RegisterPage({
- name = "Cond Section",
- path = "global",
- rows = {
+ SB2.GetRoot("CondTest"):Register({
+ sections = {
{
- id = "skipped",
- type = "header",
- name = "Should Not Appear",
- condition = function()
- return false
- end,
+ key = "condSection",
+ name = "Cond Section",
+ path = "global",
+ rows = {
+ {
+ id = "skipped",
+ type = "header",
+ name = "Should Not Appear",
+ condition = function()
+ return false
+ end,
+ },
+ { id = "shown", type = "header", name = "Should Appear" },
+ },
},
- { id = "shown", type = "header", name = "Should Appear" },
},
})
@@ -1695,7 +1759,7 @@ describe("LibSettingsBuilder", function()
assert.is_false(headerCreated)
end)
- it("RegisterPage condition=true includes entry", function()
+ it("root:Register condition=true includes entry", function()
local headerCreated = false
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1707,17 +1771,22 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND2", "CondTest2")
- SB2.RegisterPage({
- name = "Cond Section 2",
- path = "global",
- rows = {
+ SB2.GetRoot("CondTest2"):Register({
+ sections = {
{
- id = "shown",
- type = "header",
- name = "Conditional Header",
- condition = function()
- return true
- end,
+ key = "condSection2",
+ name = "Cond Section 2",
+ path = "global",
+ rows = {
+ {
+ id = "shown",
+ type = "header",
+ name = "Conditional Header",
+ condition = function()
+ return true
+ end,
+ },
+ },
},
},
})
@@ -1726,7 +1795,7 @@ describe("LibSettingsBuilder", function()
assert.is_true(headerCreated)
end)
- it("RegisterPage passes hidden predicates to header initializers", function()
+ it("root:Register passes hidden predicates to header initializers", function()
local capturedHeader
local origHeader = CreateSettingsListSectionHeaderInitializer
_G.CreateSettingsListSectionHeaderInitializer = function(text)
@@ -1736,17 +1805,22 @@ describe("LibSettingsBuilder", function()
local SB2 = createSB2("COND3", "CondTest3")
- SB2.RegisterPage({
- name = "Cond Section 3",
- path = "global",
- rows = {
+ SB2.GetRoot("CondTest3"):Register({
+ sections = {
{
- id = "shown",
- type = "header",
- name = "Conditional Header",
- hidden = function()
- return true
- end,
+ key = "condSection3",
+ name = "Cond Section 3",
+ path = "global",
+ rows = {
+ {
+ id = "shown",
+ type = "header",
+ name = "Conditional Header",
+ hidden = function()
+ return true
+ end,
+ },
+ },
},
},
})
@@ -1757,23 +1831,24 @@ describe("LibSettingsBuilder", function()
assert.is_false(capturedHeader._shownPredicates[1]())
end)
- it("RegisterPage rootCategory=true uses root instead of subcategory", function()
+ it("root:Register root pages stay on the root category", function()
local SB2 = createSB2("ROOT1", "RootTest")
+ local root = SB2.GetRoot("RootTest")
- SB2.RegisterPage({
- name = "Root Section",
- rootCategory = true,
- path = "global",
- rows = {
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
+ root:Register({
+ page = {
+ key = "rootSection",
+ path = "global",
+ rows = {
+ { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
+ },
},
})
- -- rootCategory=true should NOT create a subcategory
- assert.is_nil(SB2.GetSubcategoryID("Root Section"))
+ assert.are.equal("RootTest", root:GetPage("rootSection"):GetID())
end)
- it("RegisterPage canvas rows embed a canvas frame", function()
+ it("root:Register canvas rows embed a canvas frame", function()
local SB2 = createSB2("CANVAS1", "CanvasTest")
local canvasFrame = {
@@ -1790,11 +1865,16 @@ describe("LibSettingsBuilder", function()
return origEmbed(canvas, height, spec)
end
- SB2.RegisterPage({
- name = "Canvas Section",
- path = "global",
- rows = {
- { id = "myCanvas", type = "canvas", canvas = canvasFrame, height = 400 },
+ SB2.GetRoot("CanvasTest"):Register({
+ sections = {
+ {
+ key = "canvasSection",
+ name = "Canvas Section",
+ path = "global",
+ rows = {
+ { id = "myCanvas", type = "canvas", canvas = canvasFrame, height = 400 },
+ },
+ },
},
})
@@ -1934,8 +2014,12 @@ describe("LibSettingsBuilder", function()
varPrefix = "Handler",
onChanged = function() end,
})
- SBH.CreateRootCategory("HandlerTest")
- SBH.CreateSubcategory("HandlerSection")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "handlerSection",
+ name = "HandlerSection",
+ rows = {},
+ }, "HandlerTest")
+ SBH._currentSubcategory = page._category
local store = { myVal = true }
local _, setting = SBH.Checkbox({
@@ -1961,8 +2045,12 @@ describe("LibSettingsBuilder", function()
varPrefix = "Handler",
onChanged = function() end,
})
- SBH.CreateRootCategory("HandlerTest2")
- SBH.CreateSubcategory("HandlerSection2")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "handlerSection2",
+ name = "HandlerSection2",
+ rows = {},
+ }, "HandlerTest2")
+ SBH._currentSubcategory = page._category
local store = { scale = 0.75 }
local _, setting = SBH.Slider({
@@ -2008,8 +2096,12 @@ describe("LibSettingsBuilder", function()
it("errors when handler mode missing set", function()
local LSB = LibStub("LibSettingsBuilder-1.0")
local SBH = LSB:New({ varPrefix = "H", onChanged = function() end })
- SBH.CreateRootCategory("HErr")
- SBH.CreateSubcategory("HErrS")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "hErrS",
+ name = "HErrS",
+ rows = {},
+ }, "HErr")
+ SBH._currentSubcategory = page._category
assert.has.errors(function()
SBH.Checkbox({
@@ -2025,8 +2117,12 @@ describe("LibSettingsBuilder", function()
it("errors when handler mode missing key", function()
local LSB = LibStub("LibSettingsBuilder-1.0")
local SBH = LSB:New({ varPrefix = "H2", onChanged = function() end })
- SBH.CreateRootCategory("HErr2")
- SBH.CreateSubcategory("HErrS2")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "hErrS2",
+ name = "HErrS2",
+ rows = {},
+ }, "HErr2")
+ SBH._currentSubcategory = page._category
assert.has.errors(function()
SBH.Checkbox({
@@ -2042,8 +2138,12 @@ describe("LibSettingsBuilder", function()
it("path mode errors without pathAdapter", function()
local LSB = LibStub("LibSettingsBuilder-1.0")
local SBH = LSB:New({ varPrefix = "NP", onChanged = function() end })
- SBH.CreateRootCategory("NoPath")
- SBH.CreateSubcategory("NoPathS")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "noPathS",
+ name = "NoPathS",
+ rows = {},
+ }, "NoPath")
+ SBH._currentSubcategory = page._category
assert.has.errors(function()
SBH.Checkbox({
@@ -2056,8 +2156,12 @@ describe("LibSettingsBuilder", function()
it("Control dispatches handler-mode checkbox", function()
local LSB = LibStub("LibSettingsBuilder-1.0")
local SBH = LSB:New({ varPrefix = "Disp", onChanged = function() end })
- SBH.CreateRootCategory("DispTest")
- SBH.CreateSubcategory("DispSect")
+ local _, page = setCurrentCategoryFromSection(SBH, {
+ key = "dispSect",
+ name = "DispSect",
+ rows = {},
+ }, "DispTest")
+ SBH._currentSubcategory = page._category
local store = { flag = false }
local _, setting = SBH.Control({
@@ -2208,16 +2312,33 @@ describe("LibSettingsBuilder", function()
})
end
- it("stores onShow/onHide callbacks when provided in RegisterPage", function()
+ local function registerLifecycleSection(sb, opts)
+ local root = sb.GetRoot(opts.rootName or "Lifecycle")
+ local key = opts.key or "page1"
+
+ root:Register({
+ sections = {
+ {
+ key = key,
+ name = opts.name or "Page1",
+ onShow = opts.onShow,
+ onHide = opts.onHide,
+ rows = opts.rows or {},
+ },
+ },
+ })
+
+ local page = root:GetSection(key):GetPage("main")
+ return root, page, page._category
+ end
+
+ it("stores onShow/onHide callbacks when provided declaratively", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onShow = function() end,
onHide = function() end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
assert.is_table(LSB._pageLifecycleCallbacks[cat])
assert.is_function(LSB._pageLifecycleCallbacks[cat].onShow)
assert.is_function(LSB._pageLifecycleCallbacks[cat].onHide)
@@ -2231,28 +2352,22 @@ describe("LibSettingsBuilder", function()
it("fires onShow when DisplayCategory is called with a tracked category", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onShow = function() showCount = showCount + 1 end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
navigateTo(cat)
assert.are.equal(1, showCount)
end)
it("fires onHide when switching away from a tracked category", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
local hideCount = 0
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onHide = function() hideCount = hideCount + 1 end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
local other = { _name = "Other" }
navigateTo(cat)
navigateTo(other)
@@ -2261,14 +2376,11 @@ describe("LibSettingsBuilder", function()
it("fires onHide when SettingsPanel is hidden", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
local hideCount = 0
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onHide = function() hideCount = hideCount + 1 end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
navigateTo(cat)
SettingsPanel._fireScript("OnHide")
assert.are.equal(1, hideCount)
@@ -2276,14 +2388,11 @@ describe("LibSettingsBuilder", function()
it("does not fire duplicate onShow when same category re-selected", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onShow = function() showCount = showCount + 1 end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
navigateTo(cat)
navigateTo(cat)
assert.are.equal(1, showCount)
@@ -2291,23 +2400,22 @@ describe("LibSettingsBuilder", function()
it("does not fire callbacks for categories without lifecycle hooks", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
- sb.RegisterPage({ name = "Plain", rows = {} })
- local untracked = sb._subcategories["Plain"]
+ local _, _, untracked = registerLifecycleSection(sb, {
+ key = "plain",
+ name = "Plain",
+ })
+
-- Should not error
navigateTo(untracked)
end)
it("clears active category on panel hide so next open fires onShow", function()
local sb = makeSB()
- sb.CreateRootCategory("Lifecycle")
local showCount = 0
- sb.RegisterPage({
- name = "Page1",
+ local _, _, cat = registerLifecycleSection(sb, {
onShow = function() showCount = showCount + 1 end,
- rows = {},
})
- local cat = sb._subcategories["Page1"]
+
navigateTo(cat)
SettingsPanel._fireScript("OnHide")
navigateTo(cat)
@@ -2354,13 +2462,18 @@ describe("LibSettingsBuilder", function()
varPrefix = "D",
onChanged = function() end,
})
- sb.CreateRootCategory("Deferred")
+ local root = sb.GetRoot("Deferred")
local showCount = 0
- sb.RegisterPage({
- name = "Page1",
- onShow = function() showCount = showCount + 1 end,
- rows = {},
+ root:Register({
+ sections = {
+ {
+ key = "page1",
+ name = "Page1",
+ onShow = function() showCount = showCount + 1 end,
+ rows = {},
+ },
+ },
})
-- Hooks not yet installed — deferred frame should exist
@@ -2374,7 +2487,7 @@ describe("LibSettingsBuilder", function()
assert.is_true(lsb._pageLifecycleHooked)
-- Hooks should now work
- local cat = sb._subcategories["Page1"]
+ local cat = root:GetSection("page1"):GetPage("main")._category
SettingsPanel:SetCurrentCategory(cat)
SettingsPanel:DisplayCategory(cat)
assert.are.equal(1, showCount)
@@ -2460,7 +2573,7 @@ describe("LibSettingsBuilder", function()
assert.are.equal(1, #SB._categoryRefreshables[category])
end)
- it("RegisterPage dispatches list rows through SB.List", function()
+ it("root:Register dispatches list rows through SB.List", function()
local called
local originalList = SB.List
SB.List = function(spec)
@@ -2468,17 +2581,22 @@ describe("LibSettingsBuilder", function()
return { _type = "list" }
end
- SB.RegisterPage({
- name = "Collection Page",
- rows = {
+ SB.GetRoot("TestAddon"):Register({
+ sections = {
{
- id = "items",
- type = "list",
- height = 200,
- variant = "swatch",
- items = function()
- return {}
- end,
+ key = "collectionPage",
+ name = "Collection Page",
+ rows = {
+ {
+ id = "items",
+ type = "list",
+ height = 200,
+ variant = "swatch",
+ items = function()
+ return {}
+ end,
+ },
+ },
},
},
})
@@ -2490,9 +2608,10 @@ describe("LibSettingsBuilder", function()
assert.are.equal("swatch", called.variant)
end)
- it("RefreshCategory reevaluates visible frames and dynamic refreshables", function()
+ it("page:Refresh reevaluates visible frames and dynamic refreshables", function()
local frames = createSettingsPanelMock()
- local category = SB._currentSubcategory
+ local page = SB.GetRoot("TestAddon"):GetSection("testSection"):GetPage("main")
+ local category = page._category
local refreshed = 0
local frame = createScriptableFrame()
frame.EvaluateState = function(self)
@@ -2512,7 +2631,7 @@ describe("LibSettingsBuilder", function()
},
}
- SB.RefreshCategory(category)
+ page:Refresh()
assert.are.equal(1, refreshed)
assert.is_true(frame._evaluated)
@@ -3223,7 +3342,7 @@ describe("LibSettingsBuilder", function()
assert.are.equal("Global Font", setting:GetValue())
end)
- it("RegisterPage dispatches custom type through SB.Custom", function()
+ it("root:Register dispatches custom type through SB.Custom", function()
local capturedTemplate
local settings = Settings
local origCEI = settings.CreateElementInitializer
@@ -3232,17 +3351,22 @@ describe("LibSettingsBuilder", function()
return origCEI(template, data)
end)
- SB.RegisterPage({
- name = "Test Custom Section",
- path = "global",
- rows = {
- { id = "testHeader", type = "header", name = "Appearance" },
+ SB.GetRoot("TestAddon"):Register({
+ sections = {
{
- id = "fontPicker",
- type = "custom",
- path = "font",
- name = "Font",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
+ key = "testCustomSection",
+ name = "Test Custom Section",
+ path = "global",
+ rows = {
+ { id = "testHeader", type = "header", name = "Appearance" },
+ {
+ id = "fontPicker",
+ type = "custom",
+ path = "font",
+ name = "Font",
+ template = "LibLSMSettingsWidgets_FontPickerTemplate",
+ },
+ },
},
},
})
@@ -3264,4 +3388,174 @@ describe("LibSettingsBuilder", function()
assert.is_nil(init.InitFrame)
end)
end)
+
+ describe("root declarative API", function()
+ it("GetRoot is idempotent and rejects conflicting names", function()
+ local sb = createSB2("ROOTAPI1", "Root API")
+ local rootA = sb.GetRoot("Root API")
+ local rootB = sb.GetRoot("Root API")
+
+ assert.are.equal(rootA, rootB)
+ assert.has_error(function()
+ sb.GetRoot("Other Root")
+ end)
+ end)
+
+ it("registers a root page on the root category and rejects a second root page", function()
+ local sb = createSB2("ROOTAPI2", "Root API")
+ local root = sb.GetRoot("Root API")
+ root:Register({
+ page = {
+ key = "about",
+ rows = {
+ { type = "info", name = "Version", value = "1.0" },
+ },
+ },
+ })
+
+ assert.are.equal("Root API", root:GetPage("about"):GetID())
+
+ assert.has_error(function()
+ root:Register({
+ page = {
+ key = "second",
+ rows = {
+ { type = "info", name = "Other", value = "2.0" },
+ },
+ },
+ })
+ end)
+ end)
+
+ it("flattens single-page sections and preserves path-bound settings", function()
+ local sb = createSB2("ROOTAPI3", "Root API")
+ local root = sb.GetRoot("Root API")
+ local captured = TestHelpers.CollectSettings(function()
+ root:Register({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ path = "global",
+ rows = {
+ {
+ type = "checkbox",
+ path = "hideWhenMounted",
+ name = "Hide When Mounted",
+ },
+ },
+ },
+ },
+ })
+ end)
+
+ local page = root:GetSection("general"):GetPage("main")
+ local _, setting = next(captured)
+
+ assert.are.equal("Root API.General", page:GetID())
+ assert.is_true(setting:GetValue())
+ setting:SetValue(false)
+ assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted)
+ end)
+
+ it("nests multi-page sections and honors explicit nested display for single-page sections", function()
+ local sb = createSB2("ROOTAPI4", "Root API")
+ local root = sb.GetRoot("Root API")
+ root:Register({
+ sections = {
+ {
+ key = "multi",
+ name = "Multi",
+ pages = {
+ { key = "first", name = "First", rows = {} },
+ { key = "second", name = "Second", rows = {} },
+ },
+ },
+ {
+ key = "nested",
+ name = "Nested",
+ display = "nested",
+ rows = {},
+ },
+ },
+ })
+
+ local second = root:GetSection("multi"):GetPage("second")
+ local only = root:GetSection("nested"):GetPage("main")
+
+ assert.are.equal("Root API.Multi.Second", second:GetID())
+ assert.are.equal("Root API.Nested.Nested", only:GetID())
+ end)
+
+ it("injects page as first arg to onClick callbacks in declarative rows", function()
+ local sb = createSB2("ROOTAPI5", "Root API")
+ local root = sb.GetRoot("Root API")
+ local receivedPage
+ root:Register({
+ sections = {
+ {
+ key = "clicks",
+ name = "Clicks",
+ rows = {
+ {
+ type = "button",
+ name = "Test",
+ buttonText = "Test",
+ onClick = function(pg) receivedPage = pg end,
+ },
+ },
+ },
+ },
+ })
+
+ local page = root:GetSection("clicks"):GetPage("main")
+
+ local layout = page._category:GetLayout()
+ local inits = layout._initializers
+ local buttonInit
+ for i = #inits, 1, -1 do
+ if inits[i]._type == "button" then
+ buttonInit = inits[i]
+ break
+ end
+ end
+ assert.is_not_nil(buttonInit, "button initializer not found")
+ buttonInit._onClick()
+ assert.are.equal(page, receivedPage)
+ end)
+
+ it("injects page as third arg to onSet callbacks in declarative rows", function()
+ local sb = createSB2("ROOTAPI6", "Root API")
+ local root = sb.GetRoot("Root API")
+ local receivedArgs = {}
+ local captured = TestHelpers.CollectSettings(function()
+ root:Register({
+ sections = {
+ {
+ key = "onset",
+ name = "OnSet",
+ path = "global",
+ rows = {
+ {
+ type = "checkbox",
+ path = "hideWhenMounted",
+ name = "Hide When Mounted",
+ onSet = function(value, _, pg)
+ receivedArgs = { value = value, page = pg }
+ end,
+ },
+ },
+ },
+ },
+ })
+ end)
+
+ local page = root:GetSection("onset"):GetPage("main")
+ local _, setting = next(captured)
+
+ setting:SetValue(false)
+ assert.are.equal(false, receivedArgs.value)
+ assert.are.equal(page, receivedArgs.page)
+ end)
+ end)
end)
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index d3864745..cce5b9b4 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -76,8 +76,18 @@ describe("LibSettingsBuilder Collections", function()
varPrefix = "COLL",
onChanged = function() end,
})
- SB.CreateRootCategory("Collections")
- local category = SB.CreateSubcategory("Rows")
+ local root = SB.GetRoot("Collections")
+ root:Register({
+ sections = {
+ {
+ key = "rows",
+ name = "Rows",
+ rows = {},
+ },
+ },
+ })
+ local page = root:GetSection("rows"):GetPage("main")
+ local category = page._category
local listInit = SB.List({
category = category,
@@ -97,6 +107,6 @@ describe("LibSettingsBuilder Collections", function()
assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, listInit._template)
assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, sectionInit._template)
- assert.is_function(SB.RefreshCategory)
+ assert.is_function(page.Refresh)
end)
end)
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 007c3042..98204f94 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -11,296 +11,839 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
local installPageLifecycleHooks = internal.installPageLifecycleHooks
+local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
+local BuilderMixin = lib.BuilderMixin
+
+local SectionMethods = {}
+local PageMethods = {}
+
+local DISPATCH = {
+ checkbox = "Checkbox",
+ slider = "Slider",
+ dropdown = "Dropdown",
+ color = "Color",
+ input = "Input",
+ custom = "Custom",
+}
+
+local COMPOSITE_ROW_DISPATCH = {
+ border = function(builder, path, spec)
+ local result = builder:BorderGroup(path, spec)
+ return result.enabledInit, result.enabledSetting
+ end,
+ fontOverride = function(builder, path, spec)
+ local result = builder:FontOverrideGroup(path, spec)
+ return result.enabledInit, result.enabledSetting
+ end,
+ heightOverride = function(builder, path, spec)
+ return builder:HeightOverrideSlider(path, spec)
+ end,
+}
+
+local PROXY_ROW_TYPES = {
+ checkbox = true,
+ slider = true,
+ dropdown = true,
+ color = true,
+ input = true,
+ custom = true,
+}
+
+local proxyMethods = {}
+local proxyMT = {
+ __index = function(self, key)
+ local method = proxyMethods[key]
+ if method then
+ return method
+ end
-function lib._installUtility(SB, env)
- local getCanvasLayoutMetrics = env.getCanvasLayoutMetrics
+ local target = rawget(self, "_lsbTarget")
+ if not target then
+ return nil
+ end
- function SB.SetCanvasLayoutDefaults(overrides)
- if not overrides then
- return lib.CanvasLayoutDefaults
+ local value = target[key]
+ if type(value) == "function" then
+ return function(_, ...)
+ return value(target, ...)
+ end
end
- return copyMixin(lib.CanvasLayoutDefaults, overrides)
+ return value
+ end,
+}
+
+function proxyMethods:_lsbBind(target)
+ self._lsbTarget = target
+ return target
+end
+
+function BuilderMixin:SetCanvasLayoutDefaults(overrides)
+ if not overrides then
+ return lib.CanvasLayoutDefaults
end
- function SB.ConfigureCanvasLayout(layout, overrides)
- assert(layout, "ConfigureCanvasLayout: layout is required")
- if not overrides then
- return getCanvasLayoutMetrics(layout)
- end
+ return copyMixin(lib.CanvasLayoutDefaults, overrides)
+end
+
+function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
+ assert(layout, "ConfigureCanvasLayout: layout is required")
+ if not overrides then
+ return getCanvasLayoutMetrics(layout)
+ end
+
+ layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides)
+ return layout._metrics
+end
- layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides)
- return layout._metrics
+function BuilderMixin:Control(spec)
+ local methodName = DISPATCH[spec.type]
+ assert(methodName, "Control: unknown type '" .. tostring(spec.type) .. "'")
+ return self[methodName](self, spec)
+end
+
+local function refreshCategory(builder, category)
+ if not category then
+ return
end
- function SB.RegisterCategories()
- if SB._rootCategory then
- Settings.RegisterAddOnCategory(SB._rootCategory)
+ local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil
+ local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category
+
+ local refreshables = builder._categoryRefreshables[category] or {}
+ for _, initializer in ipairs(refreshables) do
+ if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer)
end
end
- function SB.GetRootCategoryID()
- return SB._rootCategory and SB._rootCategory:GetID()
+ if not isVisible then
+ return
+ end
+
+ local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ local scrollBox = settingsList and settingsList.ScrollBox
+ if scrollBox and scrollBox.ForEachFrame then
+ scrollBox:ForEachFrame(function(frame)
+ local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer
+ if frame.EvaluateState then
+ frame:EvaluateState()
+ end
+ if initializer and initializer._lsbRefreshFrame then
+ initializer._lsbRefreshFrame(frame, initializer)
+ end
+ end)
+ end
+end
+
+local function shouldProcessRow(row)
+ local condition = row and row.condition
+ return condition == nil
+ or (type(condition) == "function" and condition())
+ or (type(condition) ~= "function" and condition)
+end
+
+local function resolvePagePath(pagePath, rowPath)
+ if not rowPath or rowPath:find("%.") or pagePath == "" then
+ return rowPath or pagePath
+ end
+ return pagePath .. "." .. rowPath
+end
+
+local function copyDeclarativeRowSpec(row)
+ local spec = copyMixin({}, row)
+ spec.id = nil
+ spec.condition = nil
+ if spec.desc and not spec.tooltip then
+ spec.tooltip = spec.desc
end
+ spec.desc = nil
+ return spec
+end
- function SB.GetSubcategoryID(name)
- local category = SB._subcategories[name]
- return category and category:GetID()
+local function resolveDeclarativeParent(sourceName, created, rowID, spec)
+ if type(spec.parent) ~= "string" then
+ return
end
- function SB.GetRootCategory()
- return SB._rootCategory
+ local ref = created[spec.parent]
+ assert(
+ ref,
+ sourceName .. ": parent '" .. spec.parent .. "' not found for row '" .. tostring(rowID or spec.name or spec.type) .. "'"
+ )
+
+ spec.parent = ref.initializer
+ local parentCheck = spec.parentCheck
+ if parentCheck == "checked" or parentCheck == "notChecked" then
+ local setting = ref.setting
+ assert(setting, sourceName .. ": parentCheck='" .. parentCheck .. "' requires a parent setting")
+ spec.parentCheck = parentCheck == "checked" and function()
+ return setting:GetValue()
+ end or function()
+ return not setting:GetValue()
+ end
end
+end
- function SB.GetSubcategory(name)
- return SB._subcategories[name]
+local function createProxy(kind)
+ return setmetatable({ _lsbProxyKind = kind }, proxyMT)
+end
+
+local function bindProxy(proxy, target)
+ if proxy then
+ proxy:_lsbBind(target)
end
+ return target
+end
- function SB.HasCategory(category)
- return category ~= nil and SB._layouts[category] ~= nil
+local function unwrapProxy(value, kind, sourceName)
+ if type(value) ~= "table" or value._lsbProxyKind ~= kind then
+ return value
end
- local DISPATCH = {
- checkbox = "Checkbox",
- slider = "Slider",
- dropdown = "Dropdown",
- color = "Color",
- input = "Input",
- custom = "Custom",
- }
+ local target = rawget(value, "_lsbTarget")
+ assert(target, sourceName .. ": dependent control was not materialized yet")
+ return target
+end
+
+local function callBuilder(builder, methodName, ...)
+ return builder[methodName](builder, ...)
+end
- function SB.Control(spec)
- local fn = SB[DISPATCH[spec.type]]
- assert(fn, "Control: unknown type '" .. tostring(spec.type) .. "'")
- return fn(spec)
+local function registerLabeledList(page, spec, methodName)
+ local builder = page._builder
+ if spec.label then
+ local labelInit = builder:Subheader({
+ name = spec.label,
+ disabled = spec.disabled,
+ hidden = spec.hidden,
+ category = page._category,
+ })
+ spec.parent = spec.parent or labelInit
end
- function SB.RefreshCategory(categoryOrName)
- local category = categoryOrName
- if type(categoryOrName) == "string" then
- category = SB._subcategories[categoryOrName]
- or (categoryOrName == SB._rootCategoryName and SB._rootCategory)
- end
- if not category then
- return
- end
+ local results = callBuilder(
+ builder,
+ methodName,
+ resolvePagePath(page.path or "", spec.path),
+ spec.defs or {},
+ spec
+ )
+ return results[1] and results[1].initializer, results[1] and results[1].setting
+end
- local currentCategory = SettingsPanel and SettingsPanel.GetCurrentCategory and SettingsPanel:GetCurrentCategory() or nil
- local isVisible = SettingsPanel and SettingsPanel.IsShown and SettingsPanel:IsShown() and currentCategory == category
+local function registerDeclarativeRow(sourceName, page, row, created)
+ local rowType = row.type
+ assert(rowType, sourceName .. ": each row requires a type")
- local refreshables = SB._categoryRefreshables[category] or {}
- for _, initializer in ipairs(refreshables) do
- if initializer._lsbActiveFrame and initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame(initializer._lsbActiveFrame, initializer)
- end
- end
+ local builder = page._builder
+ local spec = copyDeclarativeRowSpec(row)
+ if page.disabled and spec.disabled == nil then
+ spec.disabled = page.disabled
+ end
+ if page.hidden and spec.hidden == nil then
+ spec.hidden = page.hidden
+ end
+ if spec.category == nil then
+ spec.category = page._category
+ end
- if not isVisible then
- return
- end
+ resolveDeclarativeParent(sourceName, created, row.id, spec)
+ spec._page = page
- local settingsList = SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
- local scrollBox = settingsList and settingsList.ScrollBox
- if scrollBox and scrollBox.ForEachFrame then
- scrollBox:ForEachFrame(function(frame)
- local initializer = frame.GetElementData and frame:GetElementData() or frame._lsbInitializer
- if frame.EvaluateState then
- frame:EvaluateState()
- end
- if initializer and initializer._lsbRefreshFrame then
- initializer._lsbRefreshFrame(frame, initializer)
- end
- end)
+ if spec.onClick then
+ local original = spec.onClick
+ spec.onClick = function(...)
+ return original(page, ...)
end
end
- local COMPOSITE_ROW_DISPATCH = {
- border = function(path, spec)
- local result = SB.BorderGroup(path, spec)
- return result.enabledInit, result.enabledSetting
- end,
- fontOverride = function(path, spec)
- local result = SB.FontOverrideGroup(path, spec)
- return result.enabledInit, result.enabledSetting
- end,
- heightOverride = function(path, spec)
- return SB.HeightOverrideSlider(path, spec)
- end,
- }
-
- local PROXY_ROW_TYPES = {
- checkbox = true,
- slider = true,
- dropdown = true,
- color = true,
- input = true,
- custom = true,
- }
-
- local function shouldProcessRow(row)
- local condition = row and row.condition
- return condition == nil
- or (type(condition) == "function" and condition())
- or (type(condition) ~= "function" and condition)
- end
-
- local function resolvePagePath(pagePath, rowPath)
- if not rowPath or rowPath:find("%.") or pagePath == "" then
- return rowPath or pagePath
+ local initializer, setting
+ if rowType == "button" then
+ initializer = builder:Button(spec)
+ elseif rowType == "canvas" then
+ initializer = builder:EmbedCanvas(spec.canvas, spec.height, spec)
+ elseif rowType == "checkboxList" then
+ initializer, setting = registerLabeledList(page, spec, "CheckboxList")
+ elseif rowType == "colorList" then
+ initializer, setting = registerLabeledList(page, spec, "ColorPickerList")
+ elseif rowType == "header" then
+ initializer = builder:Header(spec)
+ elseif rowType == "info" then
+ initializer = builder:InfoRow(spec)
+ elseif rowType == "list" then
+ initializer = builder:List(spec)
+ elseif rowType == "pageActions" then
+ initializer = builder:PageActions(spec)
+ elseif rowType == "sectionList" then
+ initializer = builder:SectionList(spec)
+ elseif rowType == "subheader" then
+ initializer = builder:Subheader(spec)
+ elseif COMPOSITE_ROW_DISPATCH[rowType] then
+ initializer, setting = COMPOSITE_ROW_DISPATCH[rowType](
+ builder,
+ resolvePagePath(page.path or "", spec.path),
+ spec
+ )
+ elseif PROXY_ROW_TYPES[rowType] then
+ if not spec.get then
+ spec.path = resolvePagePath(page.path or "", spec.path)
+ elseif not spec.key then
+ spec.key = row.id
+ end
+ if spec.get and not spec.key then
+ error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id")
end
- return pagePath .. "." .. rowPath
+ spec.type = rowType
+ initializer, setting = builder:Control(spec)
+ else
+ error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
end
- local function copyDeclarativeRowSpec(row)
- local spec = copyMixin({}, row)
- spec.id, spec.condition = nil, nil
- if spec.desc and not spec.tooltip then
- spec.tooltip = spec.desc
+ if row.id then
+ created[row.id] = { initializer = initializer, setting = setting }
+ end
+end
+
+local function createManagedSubcategory(builder, name, parentCategory)
+ local previous = builder._currentSubcategory
+ local category = builder:_createSubcategory(name, parentCategory)
+ builder._currentSubcategory = previous
+ return category
+end
+
+local function assertRootConfigured(root, sourceName)
+ assert(root._category, sourceName .. ": builder was created without config.name")
+end
+
+local function sortByOrder(items)
+ table.sort(items, function(left, right)
+ local leftOrder = left.order or left._sequence
+ local rightOrder = right.order or right._sequence
+ if leftOrder == rightOrder then
+ return left._sequence < right._sequence
end
- spec.desc = nil
- return spec
+ return leftOrder < rightOrder
+ end)
+ return items
+end
+
+local function assertPageMutable(page, sourceName)
+ assert(not page._registered, sourceName .. ": page is already registered")
+ if page._section then
+ assert(not page._section._registered, sourceName .. ": section is already registered")
+ end
+end
+
+local function bindPageLifecycle(page)
+ if page._onShow or page._onHide then
+ lib._pageLifecycleCallbacks[page._category] = {
+ onShow = page._onShow,
+ onHide = page._onHide,
+ }
+ installPageLifecycleHooks()
end
+end
- local function resolveDeclarativeParent(sourceName, created, rowID, spec)
- if type(spec.parent) ~= "string" then
- return
+local function prepareSpec(page, sourceName, spec)
+ local prepared = copyMixin({}, spec)
+ prepared._page = page
+ if prepared.category == nil then
+ prepared.category = page._category
+ end
+ if prepared.parent then
+ prepared.parent = unwrapProxy(prepared.parent, "initializer", sourceName)
+ end
+ if prepared.onClick then
+ local original = prepared.onClick
+ prepared.onClick = function(...)
+ return original(page, ...)
end
+ end
+ return prepared
+end
+
+local function prepareControlSpec(page, sourceName, spec)
+ local prepared = prepareSpec(page, sourceName, spec)
+ if not prepared.get and prepared.path then
+ prepared.path = resolvePagePath(page.path or "", prepared.path)
+ end
+ return prepared
+end
+
+local function queuePageOperation(page, sourceName, fn)
+ assertPageMutable(page, sourceName)
+ page._operations[#page._operations + 1] = fn
+end
- local ref = created[spec.parent]
- assert(
- ref,
- sourceName .. ": parent '" .. spec.parent .. "' not found for row '" .. tostring(rowID or spec.name or spec.type) .. "'"
+local function queueSpecPair(page, sourceName, methodName, spec)
+ local initializerProxy = createProxy("initializer")
+ local settingProxy = createProxy("setting")
+ local snapshot = copyMixin({}, spec or {})
+
+ queuePageOperation(page, sourceName, function()
+ local initializer, setting = callBuilder(
+ page._builder,
+ methodName,
+ prepareControlSpec(page, sourceName, snapshot)
)
- spec.parent = ref.initializer
- local parentCheck = spec.parentCheck
- if parentCheck == "checked" or parentCheck == "notChecked" then
- local setting = ref.setting
- assert(setting, sourceName .. ": parentCheck='" .. parentCheck .. "' requires a parent setting")
- spec.parentCheck = parentCheck == "checked" and function()
- return setting:GetValue()
- end or function()
- return not setting:GetValue()
- end
- end
+ bindProxy(initializerProxy, initializer)
+ bindProxy(settingProxy, setting)
+ end)
+
+ return initializerProxy, settingProxy
+end
+
+local function queueSpecInit(page, sourceName, methodName, spec)
+ local initializerProxy = createProxy("initializer")
+ local snapshot = copyMixin({}, spec or {})
+
+ queuePageOperation(page, sourceName, function()
+ local initializer = callBuilder(page._builder, methodName, prepareSpec(page, sourceName, snapshot))
+ bindProxy(initializerProxy, initializer)
+ end)
+
+ return initializerProxy
+end
+
+local function queueHeightOverride(page, sectionPath, spec)
+ local initializerProxy = createProxy("initializer")
+ local settingProxy = createProxy("setting")
+ local snapshot = copyMixin({}, spec or {})
+
+ queuePageOperation(page, "page:HeightOverrideSlider", function()
+ local initializer, setting = callBuilder(
+ page._builder,
+ "HeightOverrideSlider",
+ resolvePagePath(page.path or "", sectionPath),
+ prepareSpec(page, "page:HeightOverrideSlider", snapshot)
+ )
+ bindProxy(initializerProxy, initializer)
+ bindProxy(settingProxy, setting)
+ end)
+
+ return initializerProxy, settingProxy
+end
+
+local function queueCompositeGroup(page, sourceName, methodName, basePath, spec, fields)
+ local result = {}
+ for key, kind in pairs(fields) do
+ result[key] = createProxy(kind)
end
- local function registerLabeledList(page, spec, builder)
- if spec.label then
- local labelInit = SB.Subheader({ name = spec.label, disabled = spec.disabled, hidden = spec.hidden })
- spec.parent = spec.parent or labelInit
- end
- local results = builder(resolvePagePath(page.path or "", spec.path), spec.defs or {}, spec)
- return results[1] and results[1].initializer, results[1] and results[1].setting
- end
-
- local DECLARATIVE_ROW_BUILDERS = {
- button = function(spec)
- return SB.Button(spec)
- end,
- canvas = function(spec)
- return SB.EmbedCanvas(spec.canvas, spec.height, spec)
- end,
- checkboxList = function(spec, page)
- return registerLabeledList(page, spec, SB.CheckboxList)
- end,
- colorList = function(spec, page)
- return registerLabeledList(page, spec, SB.ColorPickerList)
- end,
- header = function(spec)
- return SB.Header(spec)
- end,
- info = function(spec)
- return SB.InfoRow(spec)
- end,
- list = function(spec)
- return SB.List(spec)
- end,
- pageActions = function(spec)
- return SB.PageActions(spec)
- end,
- sectionList = function(spec)
- return SB.SectionList(spec)
- end,
- subheader = function(spec)
- return SB.Subheader(spec)
- end,
- }
-
- local function registerDeclarativeRow(sourceName, page, row, created)
- local rowType = row.type
- assert(rowType, sourceName .. ": each row requires a type")
-
- local spec = copyDeclarativeRowSpec(row)
- if page.disabled and spec.disabled == nil then
- spec.disabled = page.disabled
+ local snapshot = copyMixin({}, spec or {})
+ queuePageOperation(page, sourceName, function()
+ local actual = callBuilder(
+ page._builder,
+ methodName,
+ resolvePagePath(page.path or "", basePath),
+ prepareSpec(page, sourceName, snapshot)
+ )
+ for key in pairs(fields) do
+ bindProxy(result[key], actual[key])
end
- if page.hidden and spec.hidden == nil then
- spec.hidden = page.hidden
+ end)
+
+ return result
+end
+
+local function queueCompositeList(page, sourceName, methodName, basePath, defs, spec)
+ local proxies = {}
+ for i, def in ipairs(defs or {}) do
+ proxies[i] = {
+ key = def.key,
+ initializer = createProxy("initializer"),
+ setting = createProxy("setting"),
+ }
+ end
+
+ local snapshot = copyMixin({}, spec or {})
+ queuePageOperation(page, sourceName, function()
+ local actual = callBuilder(
+ page._builder,
+ methodName,
+ resolvePagePath(page.path or "", basePath),
+ defs,
+ prepareSpec(page, sourceName, snapshot)
+ )
+ for i, proxy in ipairs(proxies) do
+ bindProxy(proxy.initializer, actual[i] and actual[i].initializer)
+ bindProxy(proxy.setting, actual[i] and actual[i].setting)
end
+ end)
- resolveDeclarativeParent(sourceName, created, row.id, spec)
-
- local init, setting
- local builder = DECLARATIVE_ROW_BUILDERS[rowType]
- if builder then
- init, setting = builder(spec, page)
- elseif COMPOSITE_ROW_DISPATCH[rowType] then
- local path = resolvePagePath(page.path or "", spec.path)
- init, setting = COMPOSITE_ROW_DISPATCH[rowType](path, spec)
- elseif PROXY_ROW_TYPES[rowType] then
- if not spec.get then
- spec.path = resolvePagePath(page.path or "", spec.path)
- elseif not spec.key then
- spec.key = row.id
- end
- if spec.get and not spec.key then
- error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id")
+ return proxies
+end
+
+local function materializePage(page, category)
+ assert(not page._registered, "materializePage: page is already registered")
+ page._category = category
+ bindPageLifecycle(page)
+
+ local created = {}
+ for _, operation in ipairs(page._operations) do
+ operation(created)
+ end
+
+ setmetatable(page, { __index = PageMethods })
+ page._registered = true
+ if page._onRegistered then
+ page._onRegistered(page)
+ end
+ return page
+end
+
+local function appendDeclarativeRows(page, sourceName, rows)
+ queuePageOperation(page, sourceName, function(created)
+ for _, row in ipairs(rows or {}) do
+ if shouldProcessRow(row) then
+ registerDeclarativeRow(sourceName, page, row, created)
end
- spec.type = rowType
- init, setting = SB.Control(spec)
- else
- error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
end
+ end)
+ return page
+end
- if row.id then
- created[row.id] = { initializer = init, setting = setting }
- end
+function PageMethods:RegisterRows(rows)
+ return appendDeclarativeRows(self, "page:RegisterRows", rows)
+end
+
+function PageMethods:Checkbox(spec)
+ return queueSpecPair(self, "page:Checkbox", "Checkbox", spec)
+end
+
+function PageMethods:Slider(spec)
+ return queueSpecPair(self, "page:Slider", "Slider", spec)
+end
+
+function PageMethods:Dropdown(spec)
+ return queueSpecPair(self, "page:Dropdown", "Dropdown", spec)
+end
+
+function PageMethods:Input(spec)
+ return queueSpecPair(self, "page:Input", "Input", spec)
+end
+
+function PageMethods:Color(spec)
+ return queueSpecPair(self, "page:Color", "Color", spec)
+end
+
+function PageMethods:Custom(spec)
+ return queueSpecPair(self, "page:Custom", "Custom", spec)
+end
+
+function PageMethods:Button(spec)
+ return queueSpecInit(self, "page:Button", "Button", spec)
+end
+
+function PageMethods:PageActions(spec)
+ return queueSpecInit(self, "page:PageActions", "PageActions", spec)
+end
+
+function PageMethods:Header(spec)
+ if type(spec) ~= "table" then
+ spec = { name = spec }
end
+ return queueSpecInit(self, "page:Header", "Header", spec)
+end
+
+function PageMethods:Subheader(spec)
+ return queueSpecInit(self, "page:Subheader", "Subheader", spec)
+end
+
+function PageMethods:InfoRow(spec)
+ return queueSpecInit(self, "page:InfoRow", "InfoRow", spec)
+end
+
+function PageMethods:List(spec)
+ return queueSpecInit(self, "page:List", "List", spec)
+end
+
+function PageMethods:SectionList(spec)
+ return queueSpecInit(self, "page:SectionList", "SectionList", spec)
+end
- function SB.RegisterPage(page)
- assert(page.name, "RegisterPage: page.name is required")
+function PageMethods:EmbedCanvas(canvas, height, spec)
+ local initializerProxy = createProxy("initializer")
+ local snapshot = copyMixin({}, spec or {})
+
+ queuePageOperation(self, "page:EmbedCanvas", function()
+ local initializer = callBuilder(
+ self._builder,
+ "EmbedCanvas",
+ canvas,
+ height,
+ prepareSpec(self, "page:EmbedCanvas", snapshot)
+ )
+ bindProxy(initializerProxy, initializer)
+ end)
+
+ return initializerProxy
+end
+
+function PageMethods:HeightOverrideSlider(sectionPath, spec)
+ return queueHeightOverride(self, sectionPath, spec)
+end
+
+function PageMethods:FontOverrideGroup(sectionPath, spec)
+ return queueCompositeGroup(self, "page:FontOverrideGroup", "FontOverrideGroup", sectionPath, spec, {
+ enabledInit = "initializer",
+ enabledSetting = "setting",
+ fontInit = "initializer",
+ sizeInit = "initializer",
+ })
+end
+
+function PageMethods:BorderGroup(borderPath, spec)
+ return queueCompositeGroup(self, "page:BorderGroup", "BorderGroup", borderPath, spec, {
+ enabledInit = "initializer",
+ enabledSetting = "setting",
+ thicknessInit = "initializer",
+ colorInit = "initializer",
+ })
+end
- if page.rootCategory then
- SB._currentSubcategory = SB._rootCategory
+function PageMethods:ColorPickerList(basePath, defs, spec)
+ return queueCompositeList(self, "page:ColorPickerList", "ColorPickerList", basePath, defs, spec)
+end
+
+function PageMethods:CheckboxList(basePath, defs, spec)
+ return queueCompositeList(self, "page:CheckboxList", "CheckboxList", basePath, defs, spec)
+end
+
+function PageMethods:GetID()
+ assert(self._registered and self._category, "page:GetID: page is not registered")
+ return self._category:GetID()
+end
+
+function PageMethods:Refresh()
+ assert(self._registered and self._category, "page:Refresh: page is not registered")
+ refreshCategory(self._builder, self._category)
+end
+
+local function createPage(owner, key, rows, opts)
+ assert(key, "CreatePage: key is required")
+
+ opts = opts or {}
+ local ownerPath = owner.path or ""
+ local page = setmetatable({
+ _builder = owner._builder or owner,
+ _root = owner._root or owner,
+ _section = owner._root and owner or nil,
+ _key = key,
+ _name = opts.name,
+ _onShow = opts.onShow,
+ _onHide = opts.onHide,
+ _onRegistered = opts.onRegistered,
+ _operations = {},
+ _registered = false,
+ disabled = opts.disabled,
+ hidden = opts.hidden,
+ key = key,
+ name = opts.name,
+ order = opts.order,
+ path = opts.path ~= nil and opts.path or ownerPath,
+ }, { __index = PageMethods })
+
+ if rows then
+ appendDeclarativeRows(page, "CreatePage", rows)
+ end
+
+ return page
+end
+
+function SectionMethods:GetPage(key)
+ return self._pages[key]
+end
+
+local function createSectionPage(section, key, rows, opts)
+ assert(not section._registered, "createSectionPage: section is already registered")
+ assert(key, "createSectionPage: key is required")
+ assert(not section._pages[key], "createSectionPage: duplicate page key '" .. tostring(key) .. "'")
+
+ section._nextPageSequence = section._nextPageSequence + 1
+ local page = createPage(section, key, rows, opts)
+ page._sequence = section._nextPageSequence
+ section._pages[key] = page
+ section._pageList[#section._pageList + 1] = page
+ return page
+end
+
+local function registerRootPage(root, page)
+ assert(not page._section, "registerRootPage: only root-owned pages can be registered directly")
+ assert(not page._registered, "registerRootPage: page is already registered")
+ assert(
+ not root._registeredRootPage or root._registeredRootPage == page,
+ "registerRootPage: root already has a registered page"
+ )
+ root._registeredRootPage = page
+ materializePage(page, root._category)
+ return page
+end
+
+local function registerSection(section)
+ assert(not section._registered, "registerSection: section is already registered")
+ assert(#section._pageList > 0, "registerSection: section must contain at least one page")
+
+ local builder = section._builder
+ local nested = section.display == "nested" or #section._pageList > 1
+ local orderedPages = {}
+ for i = 1, #section._pageList do
+ orderedPages[i] = section._pageList[i]
+ end
+ sortByOrder(orderedPages)
+
+ if nested then
+ section._category = createManagedSubcategory(builder, section.name, section._root._category)
+ end
+
+ for _, page in ipairs(orderedPages) do
+ if nested then
+ assert(page.name and page.name ~= "", "registerSection: nested pages require spec.name")
+ materializePage(page, createManagedSubcategory(builder, page.name, section._category))
else
- SB.CreateSubcategory(page.name, page.parentCategory)
+ materializePage(page, createManagedSubcategory(builder, section.name, section._root._category))
end
+ end
- if page.onShow or page.onHide then
- lib._pageLifecycleCallbacks[SB._currentSubcategory] = {
- onShow = page.onShow,
- onHide = page.onHide,
- }
- installPageLifecycleHooks()
- end
+ section._registered = true
+ return section
+end
- local created = {}
- for _, row in ipairs(page.rows or {}) do
- if shouldProcessRow(row) then
- registerDeclarativeRow("RegisterPage", page, row, created)
+local function createSection(root, key, name, opts)
+ assert(key, "createSection: key is required")
+ assert(name, "createSection: name is required")
+ assert(not root._sections[key], "createSection: duplicate section key '" .. tostring(key) .. "'")
+
+ opts = opts or {}
+ root._nextSectionSequence = root._nextSectionSequence + 1
+ local display = opts.display or "auto"
+ assert(display == "auto" or display == "nested", "createSection: display must be 'auto' or 'nested'")
+
+ local section = setmetatable({
+ _builder = root,
+ _root = root,
+ _pages = {},
+ _pageList = {},
+ _nextPageSequence = 0,
+ _registered = false,
+ _sequence = root._nextSectionSequence,
+ display = display,
+ key = key,
+ name = name,
+ order = opts.order,
+ path = opts.path ~= nil and opts.path or key,
+ }, { __index = SectionMethods })
+
+ root._sections[key] = section
+ root._sectionList[#root._sectionList + 1] = section
+ return section
+end
+
+local function createRootPage(root, key, rows, opts)
+ assert(key, "createRootPage: key is required")
+ assert(not root._pages[key], "createRootPage: duplicate root page key '" .. tostring(key) .. "'")
+
+ local page = createPage(root, key, rows, opts)
+ page._sequence = root._nextRootPageSequence + 1
+ root._nextRootPageSequence = page._sequence
+ root._pages[key] = page
+ root._pageList[#root._pageList + 1] = page
+ return page
+end
+
+function BuilderMixin:GetSection(key)
+ return self._sections[key]
+end
+
+function BuilderMixin:GetPage(key)
+ return self._pages[key]
+end
+
+function BuilderMixin:GetRoot(name)
+ self:_initializeRoot(name)
+ return self
+end
+
+function BuilderMixin:HasCategory(category)
+ return category ~= nil and self._layouts[category] ~= nil
+end
+
+local function registerPageDefinition(owner, pageDef, defaultName)
+ assert(type(pageDef) == "table", "registerPageDefinition: page definition must be a table")
+ assert(pageDef.key, "registerPageDefinition: page definition requires key")
+
+ local creator = owner._root and createSectionPage or createRootPage
+ return creator(owner, pageDef.key, pageDef.rows, {
+ name = pageDef.name or defaultName,
+ onShow = pageDef.onShow,
+ onHide = pageDef.onHide,
+ onRegistered = pageDef.onRegistered,
+ disabled = pageDef.disabled,
+ hidden = pageDef.hidden,
+ order = pageDef.order,
+ path = pageDef.path,
+ })
+end
+
+function BuilderMixin:Register(spec)
+ assertRootConfigured(self, "Register")
+ assert(type(spec) == "table", "Register: spec must be a table")
+
+ if spec.page then
+ registerRootPage(self, registerPageDefinition(self, spec.page, self.name))
+ end
+
+ for _, sectionDef in ipairs(spec.sections or {}) do
+ assert(type(sectionDef) == "table", "Register: each section definition must be a table")
+ assert(sectionDef.key, "Register: each section requires a key")
+ assert(sectionDef.name, "Register: each section requires a name")
+
+ local section = createSection(self, sectionDef.key, sectionDef.name, {
+ display = sectionDef.display,
+ order = sectionDef.order,
+ path = sectionDef.path,
+ })
+
+ if sectionDef.pages then
+ assert(sectionDef.rows == nil, "Register: a section cannot define both rows and pages")
+ for _, pageDef in ipairs(sectionDef.pages) do
+ registerPageDefinition(section, pageDef, sectionDef.name)
end
+ else
+ createSectionPage(section, sectionDef.pageKey or "main", sectionDef.rows, {
+ name = sectionDef.pageName or (sectionDef.display == "nested" and sectionDef.name or nil),
+ onShow = sectionDef.onShow,
+ onHide = sectionDef.onHide,
+ onRegistered = sectionDef.onRegistered,
+ disabled = sectionDef.disabled,
+ hidden = sectionDef.hidden,
+ order = sectionDef.pageOrder,
+ })
end
- return SB._currentSubcategory
+ registerSection(section)
+ end
+
+ return self
+end
+
+function BuilderMixin:_initializeRoot(name)
+ if not self._rootCategory then
+ assert(name, "_initializeRoot: name is required")
+ self:_createRootCategory(name)
+ elseif name and self._rootCategoryName ~= name then
+ error("_initializeRoot: root already exists with name '" .. tostring(self._rootCategoryName) .. "'")
end
- function SB.RegisterSection(nsTable, key, section)
- nsTable.OptionsSections = nsTable.OptionsSections or {}
- nsTable.OptionsSections[key] = section
- return section
+ if not self._rootRegistered and self._rootCategory then
+ Settings.RegisterAddOnCategory(self._rootCategory)
+ self._rootRegistered = true
end
- return SB
+ self._category = self._rootCategory
+ self.name = self._rootCategoryName
+ return self
end
lib._loadState.open = nil
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index 09c9c942..94c7f2e4 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -45,15 +45,81 @@ Methods:
The built-in path helpers support numeric segments like `colors.0`.
-## Category helpers
+## Registration tree
-- `SB.CreateRootCategory(name)`
-- `SB.CreateSubcategory(name[, parentCategory])`
-- `SB.CreateCanvasSubcategory(frame, name[, parentCategory])`
-- `SB.RegisterCategories()`
-- `SB.GetRootCategoryID()`
-- `SB.GetSubcategoryID(name)`
-- `SB.RefreshCategory(categoryOrName)`
+### `SB.GetRoot(name)`
+
+Returns the singleton root handle, creating and registering the addon root category on the first call.
+
+Required on first call:
+
+- `name`
+
+Notes:
+
+- later calls return the same root handle,
+- passing a different name after creation raises an error,
+- new consumer code should call this once and reuse the returned handle.
+
+### `root:Register(spec)`
+
+Registers a declarative tree rooted at the singleton root handle.
+
+Supported fields:
+
+- `spec.page` — optional root-owned landing page definition
+- `spec.sections` — optional array of section definitions
+
+Root page definition fields:
+
+- `key`
+- `rows`
+- `name` (optional; defaults to the root name)
+- `onShow`
+- `onHide`
+- `onRegistered(page)`
+- `order`
+
+Section definition fields:
+
+- `key`
+- `name`
+- `path` (defaults to `key`)
+- `display = "auto"` or `"nested"`
+- `order`
+- either `rows` for the single-page shorthand, or `pages` for multi-page sections
+- `pageKey`, `pageName`, `pageOrder` for the single-page shorthand
+- `onShow`, `onHide`, `onRegistered(page)`, `disabled`, `hidden` for the single-page shorthand page
+
+Page definition fields inside `pages`:
+
+- `key`
+- `name` (required for nested/multi-page pages unless you want the section name as the default)
+- `path`
+- `rows`
+- `onShow`
+- `onHide`
+- `onRegistered(page)`
+- `disabled`
+- `hidden`
+- `order`
+
+Notes:
+
+- single-page sections flatten to a single leaf by default,
+- multi-page sections create a visible section node automatically,
+- `onRegistered(page)` is the intended hook for storing a registered page handle when you need later `page:Refresh()` calls.
+
+Declarative root registration is the only supported page-construction API.
+
+### Lookup and page operations
+
+- `root:GetSection(key)`
+- `root:GetPage(key)`
+- `root:HasCategory(category)`
+- `section:GetPage(key)`
+- `page:GetID()`
+- `page:Refresh()`
## Controls
@@ -67,7 +133,6 @@ Common spec fields:
- `name`
- `tooltip`
- `default`
-- `category`
- `disabled`
- `hidden`
- `parent`
@@ -190,16 +255,11 @@ Supported list variants:
- `SB.EmbedCanvas(canvas, height[, spec])`
- `SB.Button(spec)`
- `SB.PageActions(spec)`
-- `SB.RegisterSection(nsTable, key, section)`
`SB.Button` supports `confirm = true` or a custom confirm string. Confirm dialogs are registered per button to avoid cross-button collisions.
-`SB.PageActions` renders right-aligned category-header action buttons.
+`SB.PageActions` renders right-aligned page-header action buttons.
`SB.InfoRow` accepts function-backed `value` for dynamic text.
-`SB.RefreshCategory(...)` re-evaluates registered dynamic rows for a visible category.
-
-## Page registration
-
-### `SB.RegisterPage(page)`
+## Declarative page rows
Supported canonical row types:
@@ -226,6 +286,8 @@ Supported composite types:
- `colorList`
- `checkboxList`
+Declarative pages are normally supplied through `root:Register({ page = ..., sections = { ... } })`, either as a root page definition or through section `rows` / `pages` definitions.
+
## Implementation model
The library has three main families of row builders:
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index 74e98fa8..e00c7f53 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -66,7 +66,7 @@ Most library features are available with no extra XML:
Only `SB.Custom(...)` requires you to supply your own template. In that case:
1. define the template in XML,
-2. load that XML from your TOC before registering categories, and
+2. load that XML from your TOC before calling `root:Register({ ... })`, and
3. pass the template name through `spec.template`.
## Canvas layout compatibility
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index 059520a1..318e9882 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -23,8 +23,8 @@
| AceConfig stack | LibSettingsBuilder |
|---|---|
-| `RegisterOptionsTable` | `SB.RegisterPage` |
-| `AddToBlizOptions` | `SB.RegisterCategories()` |
+| `RegisterOptionsTable` | export declarative root/page/section specs |
+| `AddToBlizOptions` | `SB.GetRoot("My Addon"):Register({ page = ..., sections = { ... } })` |
| one `get`/`set` per field | one `path` per field in path mode |
| `type = "input"` | `type = "input"` or `SB.Input(...)` |
| custom refresh dance | reactive modifiers re-evaluate automatically |
@@ -50,7 +50,7 @@ local SB = LSB:New({
## Canonical row types
-`RegisterPage` uses canonical row types only:
+Declarative pages use canonical row types only:
- `checkbox`
- `slider`
@@ -72,7 +72,7 @@ local SB = LSB:New({
- composite builders for common UI groups,
- first-class dynamic list rows for complex editors,
- built-in text input rows with optional debounced previews,
-- explicit category refresh hooks for async/transient state,
+- page-owned refresh hooks for async/transient state,
- clickable slider value editing,
- deterministic dropdown ordering.
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index 442bba6c..e892af6c 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -10,13 +10,12 @@
## Choose a setup style
-- Use **page registration** if you want the shortest path to a normal settings page.
-- Use the **imperative API** if you want precise control over layout and call order.
+- Use **declarative root registration** for standard settings pages.
- Use **handler mode** if your settings are not stored in a dot-path table.
- Use `input` rows when you need text or numeric entry without building a custom template.
- Use `list` or `sectionList` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API.
-## Table-driven setup
+## Declarative root setup
```lua
local LSB = LibStub("LibSettingsBuilder-1.0")
@@ -36,79 +35,48 @@ local SB = LSB:New({
end,
})
-SB.CreateRootCategory("My Addon")
+local root = SB.GetRoot("My Addon")
-SB.RegisterPage({
- name = "General",
- path = "general",
- rows = {
+root:Register({
+ sections = {
{
- type = "checkbox",
- path = "enabled",
- name = "Enable",
- desc = "Enable or disable the addon.",
- },
- {
- type = "slider",
- path = "opacity",
- name = "Opacity",
- min = 0,
- max = 100,
- step = 1,
- },
- {
- type = "input",
- path = "spellIdText",
- name = "Spell ID",
- numeric = true,
- maxLetters = 10,
- debounce = 1,
- resolveText = function(value)
- local id = tonumber(value)
- return id and C_Spell.GetSpellName(id) or nil
- end,
+ key = "general",
+ name = "General",
+ path = "general",
+ rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = "Enable",
+ desc = "Enable or disable the addon.",
+ },
+ {
+ type = "slider",
+ path = "opacity",
+ name = "Opacity",
+ min = 0,
+ max = 100,
+ step = 1,
+ },
+ {
+ type = "input",
+ path = "spellIdText",
+ name = "Spell ID",
+ numeric = true,
+ maxLetters = 10,
+ debounce = 1,
+ resolveText = function(value)
+ local id = tonumber(value)
+ return id and C_Spell.GetSpellName(id) or nil
+ end,
+ },
+ },
},
},
})
-
-SB.RegisterCategories()
-```
-
-## Imperative setup
-
-```lua
-SB.CreateRootCategory("My Addon")
-SB.CreateSubcategory("General")
-
-SB.Checkbox({
- path = "general.enabled",
- name = "Enable",
- tooltip = "Enable or disable the addon.",
-})
-
-SB.Slider({
- path = "general.opacity",
- name = "Opacity",
- min = 0,
- max = 100,
- step = 1,
-})
-
-SB.Input({
- path = "general.spellIdText",
- name = "Spell ID",
- numeric = true,
- debounce = 1,
- resolveText = function(value)
- local id = tonumber(value)
- return id and C_Spell.GetSpellName(id) or nil
- end,
-})
-
-SB.RegisterCategories()
```
-`RegisterPage(...)` can mix persisted controls and layout-only rows freely, so it is normal to combine `checkbox`, `slider`, `input`, `header`, `subheader`, `info`, `button`, `pageActions`, `list`, `sectionList`, and `canvas` entries on one page.
+Declarative pages can mix persisted controls and layout-only rows freely, so it is normal to combine `checkbox`, `slider`, `input`, `header`, `subheader`, `info`, `button`, `pageActions`, `list`, `sectionList`, and `canvas` entries on one page.
## Handler mode
@@ -120,34 +88,40 @@ local SB = LSB:New({
end,
})
-SB.CreateRootCategory("My Addon")
-SB.CreateSubcategory("General")
-
-SB.Checkbox({
- get = function()
- return MyStore.enabled
- end,
- set = function(value)
- MyStore.enabled = value
- end,
- key = "enabled",
- default = true,
- name = "Enable",
-})
-
-SB.Input({
- get = function()
- return MyStore.searchText or ""
- end,
- set = function(value)
- MyStore.searchText = value
- end,
- key = "searchText",
- default = "",
- name = "Search",
+SB.GetRoot("My Addon"):Register({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ rows = {
+ {
+ type = "checkbox",
+ get = function()
+ return MyStore.enabled
+ end,
+ set = function(value)
+ MyStore.enabled = value
+ end,
+ key = "enabled",
+ default = true,
+ name = "Enable",
+ },
+ {
+ type = "input",
+ get = function()
+ return MyStore.searchText or ""
+ end,
+ set = function(value)
+ MyStore.searchText = value
+ end,
+ key = "searchText",
+ default = "",
+ name = "Search",
+ },
+ },
+ },
+ },
})
-
-SB.RegisterCategories()
```
## Good defaults for public addons
@@ -156,6 +130,6 @@ SB.RegisterCategories()
- Point `getStore()` and `getDefaults()` at live tables.
- Keep `onChanged` fast; use it to refresh UI, not rebuild the world.
- Use composites for repeated patterns like borders, font overrides, and positioning.
-- Prefer table-driven registration for large standard settings pages.
-- Use `SB.RefreshCategory(...)` for async or transient state that needs the visible page to redraw.
+- Prefer declarative root registration for large standard settings pages.
+- Store registered page handles through `onRegistered(page)` and call `page:Refresh()` for async or transient redraws.
- Reach for `SB.Custom(...)` or `SB.EmbedCanvas(...)` only when built-ins like `input`, `list`, and `sectionList` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index 4fab3e66..a4de6362 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -28,8 +28,8 @@ Common causes:
Usually one of these:
-- you forgot `SB.RegisterCategories()`,
-- you created a subcategory but never added controls to it,
+- you never called `SB.GetRoot("My Addon"):Register({ ... })`,
+- your registered root page or section page ended up with no visible rows,
- a `hidden` predicate is always returning `true`,
- a `custom` template was never loaded from XML.
diff --git a/Locales/en.lua b/Locales/en.lua
index 27ae8d07..b735f41f 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -202,8 +202,6 @@ L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"] =
L["SPELL_COLORS_DONT_REMOVE"] = "Don't Remove"
L["SPELL_COLORS_REMOVED_STALE_ENTRY"] = "Removed stale spell color entry: %s"
L["SPELL_COLORS_COMBAT_WARNING"] = "|cffFF0000These settings cannot be changed while in combat lockdown.|r"
-L["SPELL_COLORS_SECRETS_WARNING"] =
- "|cffFFDD3CSpell names are currently secret. Changes are blocked until you reload your UI out of combat.|r"
L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"] = "Spell color keys"
L["SPELL_COLORS_KEY_SPELL_NAME"] = "Spell name: %s"
L["SPELL_COLORS_KEY_SPELL_ID"] = "Spell ID: %s"
diff --git a/Tests/ChatCommand_spec.lua b/Tests/ChatCommand_spec.lua
index 3700629f..d5e43891 100644
--- a/Tests/ChatCommand_spec.lua
+++ b/Tests/ChatCommand_spec.lua
@@ -449,7 +449,7 @@ describe("ChatCommand migration", function()
assert.are.equal(0, openOptionsCalls)
assert.are.equal(1, #printedMessages)
- assert.are.equal("Options cannot be opened during combat. They will open when combat ends.", printedMessages[1])
+ assert.are.equal("Options cannot be opened during combat. It will open when combat ends.", printedMessages[1])
assert.is_true(mod._openOptionsAfterCombat)
end)
diff --git a/Tests/ImportExport_spec.lua b/Tests/ImportExport_spec.lua
index 0fe1f957..bda9c27e 100644
--- a/Tests/ImportExport_spec.lua
+++ b/Tests/ImportExport_spec.lua
@@ -137,23 +137,23 @@ describe("ImportExport", function()
data, errorMessage = ImportExport.DecodeData("WrongPrefix:1:ENC:CMP:SERIALIZED")
assert.is_nil(data)
- assert.is_true(errorMessage:find("not for Enhanced Cooldown Manager", 1, true) ~= nil)
+ assert.are.equal("Provided string is not a valid ECM import string (prefix: WrongPrefix)", errorMessage)
data, errorMessage = ImportExport.DecodeData("EnhancedCooldownManager:99:ENC:CMP:SERIALIZED")
assert.is_nil(data)
- assert.is_true(errorMessage:find("Incompatible import string version", 1, true) ~= nil)
+ assert.are.equal("Provided string is not a valid ECM import string (expected 1, got 99)", errorMessage)
data, errorMessage = ImportExport.DecodeData("EnhancedCooldownManager:1:bad")
assert.is_nil(data)
- assert.are.equal("Failed to decode string - it may be corrupted or incomplete", errorMessage)
+ assert.are.equal("Provided string is not a valid ECM import string - it may be corrupted or incomplete", errorMessage)
data, errorMessage = ImportExport.DecodeData("EnhancedCooldownManager:1:ENC:badcmp")
assert.is_nil(data)
- assert.are.equal("Failed to decompress data - the string may be corrupted", errorMessage)
+ assert.are.equal("Provided string is not a valid ECM import string - it may be corrupted or incomplete", errorMessage)
data, errorMessage = ImportExport.DecodeData("EnhancedCooldownManager:1:ENC:CMP:BROKEN")
assert.is_nil(data)
- assert.are.equal("Failed to deserialize data - the string may be corrupted", errorMessage)
+ assert.are.equal("Provided string is not a valid ECM import string - it may be corrupted or incomplete", errorMessage)
end)
it("ExportCurrentProfile excludes runtime cache data", function()
@@ -179,7 +179,7 @@ describe("ImportExport", function()
local data, errorMessage = ImportExport.ValidateImportString("EnhancedCooldownManager:1:ENC:CMP:SERIALIZED")
assert.is_nil(data)
- assert.are.equal("Import string does not contain profile data", errorMessage)
+ assert.are.equal("Provided string is not a valid ECM import string (profile data missing)", errorMessage)
end)
it("ApplyImportData preserves cache and runs migrations for older schemas", function()
@@ -204,11 +204,11 @@ describe("ImportExport", function()
it("ApplyImportData validates input and active profile availability", function()
local success, errorMessage = ImportExport.ApplyImportData({})
assert.is_false(success)
- assert.are.equal("Invalid import data", errorMessage)
+ assert.are.equal("Provided string is not a valid ECM import string (profile data missing)", errorMessage)
ns.Addon.db = nil
success, errorMessage = ImportExport.ApplyImportData({ profile = { schemaVersion = 10 } })
assert.is_false(success)
- assert.are.equal("No active profile to import into", errorMessage)
+ assert.are.equal("Internal error: no active profile to import into - please report this", errorMessage)
end)
end)
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 948358c1..c5f4a658 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -265,8 +265,12 @@ function TestHelpers.SetupSettingsStubs()
layout
end,
- RegisterAddOnCategory = function() end,
- OpenToCategory = function() end,
+ RegisterAddOnCategory = function(category)
+ return category
+ end,
+ OpenToCategory = function(category)
+ return category
+ end,
RegisterInitializer = function(category, initializer)
local layout = category and category.GetLayout and category:GetLayout()
@@ -1434,7 +1438,7 @@ end
function TestHelpers.SetupLibSettingsBuilder()
TestHelpers.LoadLibSettingsBuilder()
- local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1)
+ local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1) or LibStub("LibLSMSettingsWidgets-1.0")
lsmw.GetFontValues = function()
return { Expressway = "Expressway" }
end
@@ -1478,25 +1482,78 @@ function TestHelpers.SetupOptionsEnv(profile, defaults)
DisableModule = function() end,
}
- local ns = { Addon = mod, OptionsSections = {} }
+ local ns = { Addon = mod }
TestHelpers.LoadLiveConstants(ns)
ns.CloneValue = deepClone
ns.Runtime = ns.Runtime or {}
ns.Runtime.ScheduleLayoutUpdate = function() end
ns.IsDeathKnight = function()
- return false
+ local _, classToken = UnitClass("player")
+ return classToken == "DEATHKNIGHT"
end
ns.ClassUtil = {}
TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
- local SB = ns.SettingsBuilder
- SB.CreateRootCategory("Test")
-
+ local SB = ns.Settings
return SB, ns
end
+--- Register a declarative settings tree into a fresh root handle.
+--- @param SB table SettingsBuilder instance
+--- @param spec table Declarative root spec
+--- @param rootName string|nil Optional root display name
+--- @return table root Root handle
+function TestHelpers.RegisterSettingsTree(SB, spec, rootName)
+ local resolvedRootName = rootName or SB.name or "Test"
+
+ if not SB.name then
+ assert(type(SB._initializeRoot) == "function", "RegisterSettingsTree expected an initialized settings root")
+ SB:_initializeRoot(resolvedRootName)
+ elseif rootName ~= nil and rootName ~= SB.name then
+ error(("RegisterSettingsTree: root already exists with name '%s'"):format(tostring(SB.name)))
+ end
+
+ SB:Register(spec)
+ return SB
+end
+
+--- Register a single declarative section spec.
+--- @param SB table SettingsBuilder instance
+--- @param sectionSpec table Declarative section spec
+--- @param rootName string|nil Optional root display name
+--- @return table root Root handle
+--- @return table section Registered section handle
+--- @return table|nil page Registered default page handle
+function TestHelpers.RegisterSectionSpec(SB, sectionSpec, rootName)
+ local root = TestHelpers.RegisterSettingsTree(SB, { sections = { sectionSpec } }, rootName)
+ local section = root:GetSection(sectionSpec.key)
+ local page
+
+ if section then
+ if sectionSpec.pages then
+ local firstPage = sectionSpec.pages[1]
+ page = firstPage and section:GetPage(firstPage.key) or nil
+ else
+ page = section:GetPage(sectionSpec.pageKey or "main")
+ end
+ end
+
+ return root, section, page
+end
+
+--- Register a declarative root page spec.
+--- @param SB table SettingsBuilder instance
+--- @param pageSpec table Declarative root page spec
+--- @param rootName string|nil Optional root display name
+--- @return table root Root handle
+--- @return table|nil page Registered root page handle
+function TestHelpers.RegisterRootPageSpec(SB, pageSpec, rootName)
+ local root = TestHelpers.RegisterSettingsTree(SB, { page = pageSpec }, rootName)
+ return root, root:GetPage(pageSpec.key)
+end
+
--- Collect all proxy settings created during a function call.
--- Wraps Settings.RegisterProxySetting to capture variable → setting pairs.
--- @param fn function The function to run (e.g., RegisterSettings)
@@ -1597,7 +1654,7 @@ function TestHelpers.InstallPopupRecorder()
end
end
---- Set up the PowerBar tick marks options/store environment and load the live module.
+--- Set up the PowerBar tick marks options environment and load the live module.
--- @param opts table|nil Optional overrides for constants, profile, or GetCurrentClassSpec
--- @return table addonNS
function TestHelpers.SetupPowerBarTickMarksEnv(opts)
diff --git a/Tests/UI/About_spec.lua b/Tests/UI/About_spec.lua
index e1ff469f..4aa151fc 100644
--- a/Tests/UI/About_spec.lua
+++ b/Tests/UI/About_spec.lua
@@ -7,7 +7,7 @@ local TestHelpers =
describe("About section", function()
local originalGlobals
- local SB, ns
+ local SB, ns, root
setup(function()
originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
@@ -35,8 +35,6 @@ describe("About section", function()
return "<>"
end,
}
-
- TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
end)
local function findInitializer(layout, predicate)
@@ -53,17 +51,17 @@ describe("About section", function()
end)
end
- it("registers an About section", function()
- assert.is_not_nil(ns.OptionsSections["About"])
- assert.is_function(ns.OptionsSections["About"].RegisterSettings)
+ it("exports an About root page spec", function()
+ assert.is_table(ns.AboutPage)
+ assert.are.equal("about", ns.AboutPage.key)
end)
- describe("RegisterSettings", function()
+ describe("root registration", function()
local rootLayout
before_each(function()
- ns.OptionsSections["About"].RegisterSettings(SB)
- rootLayout = SB._layouts[SB._rootCategory]
+ root = TestHelpers.RegisterRootPageSpec(SB, ns.AboutPage, ns.L["ADDON_NAME"])
+ rootLayout = root._category:GetLayout()
end)
it("adds initializers to the root category layout", function()
@@ -73,19 +71,19 @@ describe("About section", function()
it("creates Author info row with sparkle text", function()
local init = findInfoRow(rootLayout, "Author")
assert.is_not_nil(init, "expected Author info row")
- assert.are.equal("<>", init.data.value)
+ assert.are.equal("<>", type(init.data.value) == "function" and init.data.value() or init.data.value)
end)
it("creates Contributors info row", function()
local init = findInfoRow(rootLayout, "Contributors")
assert.is_not_nil(init, "expected Contributors info row")
- assert.are.equal("kayti-wow", init.data.value)
+ assert.are.equal("kayti-wow", type(init.data.value) == "function" and init.data.value() or init.data.value)
end)
it("creates Version info row with leading v stripped", function()
local init = findInfoRow(rootLayout, "Version")
assert.is_not_nil(init, "expected Version info row")
- assert.are.equal("1.2.3-test", init.data.value)
+ assert.are.equal("1.2.3-test", type(init.data.value) == "function" and init.data.value() or init.data.value)
end)
it("includes Links subheader", function()
@@ -143,13 +141,15 @@ describe("About section", function()
end,
}
- SB._layouts[SB._rootCategory]._initializers = {}
- ns.OptionsSections["About"].RegisterSettings(SB)
+ local profile, defaults = TestHelpers.MakeOptionsProfile()
+ local freshSB, freshNS = TestHelpers.SetupOptionsEnv(profile, defaults)
+ freshNS.ColorUtil = ns.ColorUtil
+ local freshRoot = TestHelpers.RegisterRootPageSpec(freshSB, freshNS.AboutPage, freshNS.L["ADDON_NAME"])
+ local freshRootLayout = freshRoot._category:GetLayout()
- local rootLayout = SB._layouts[SB._rootCategory]
- local init = findInfoRow(rootLayout, "Version")
+ local init = findInfoRow(freshRootLayout, "Version")
assert.is_not_nil(init, "expected Version info row")
- assert.are.equal("Unknown", init.data.value)
+ assert.are.equal("Unknown", type(init.data.value) == "function" and init.data.value() or init.data.value)
end)
end)
end)
diff --git a/Tests/UI/AdvancedOptions_spec.lua b/Tests/UI/AdvancedOptions_spec.lua
index dcde731d..d19040c3 100644
--- a/Tests/UI/AdvancedOptions_spec.lua
+++ b/Tests/UI/AdvancedOptions_spec.lua
@@ -26,9 +26,9 @@ describe("AdvancedOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
- ns.OptionsSections["Advanced Options"].RegisterSettings(SB)
+ local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.AdvancedOptions)
+ advancedCategory = page._category
end)
- advancedCategory = SB._subcategories[ns.L["ADVANCED_OPTIONS"]]
initializers = SB._layouts[advancedCategory]._initializers
end)
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 011dc8b3..9c70d7e5 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -9,7 +9,6 @@ describe("BuffBarsOptions", function()
local originalGlobals
local BuffBarsOptions
local SpellColors
- local SB
local ns
local printedMessages
@@ -33,6 +32,7 @@ describe("BuffBarsOptions", function()
"canaccessvalue",
"canaccesstable",
"time",
+ "UnitAffectingCombat",
"InCombatLockdown",
"IsInInstance",
"IsControlKeyDown",
@@ -50,7 +50,7 @@ describe("BuffBarsOptions", function()
TestHelpers.SetupOptionsGlobals()
local profile, defaults = TestHelpers.MakeOptionsProfile()
- SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+ ns = select(2, TestHelpers.SetupOptionsEnv(profile, defaults))
_G.UnitClass = function()
return "Demon Hunter", "DEMONHUNTER", 12
@@ -77,6 +77,9 @@ describe("BuffBarsOptions", function()
_G.time = function()
return 1000
end
+ _G.UnitAffectingCombat = function()
+ return false
+ end
_G.InCombatLockdown = function()
return false
end
@@ -159,9 +162,6 @@ describe("BuffBarsOptions", function()
TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
- -- Create root category so subcategory calls work
- SB.CreateRootCategory("Test")
-
-- Load BuffBarsOptions
TestHelpers.LoadChunk("UI/BuffBarsOptions.lua", "Unable to load UI/BuffBarsOptions.lua")(nil, ns)
BuffBarsOptions = ns.BuffBarsOptions
@@ -235,56 +235,67 @@ describe("BuffBarsOptions", function()
assert.are.equal("Valid", rows[1].key.primaryKey)
end)
- it("section registers with key BuffBars", function()
- -- BuffBarsOptions should have registered itself
- assert.is_function(BuffBarsOptions.RegisterSettings)
+ it("exports a declarative BuffBars section spec", function()
+ assert.are.equal("buffBars", BuffBarsOptions.key)
+ assert.are.equal(ns.L["AURA_BARS"], BuffBarsOptions.name)
+ assert.are.equal("main", BuffBarsOptions.pages[1].key)
+ assert.are.equal("spellColors", BuffBarsOptions.pages[2].key)
end)
- it("_GetSecretNameFooterState hides the footer when all bar names are available", function()
- local state = BuffBarsOptions._GetSecretNameFooterState({
+ it("_GetSpellColorsPageState hides the secret-name warning when all bar names are available", function()
+ local state = BuffBarsOptions._GetSpellColorsPageState({
{ key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
})
- assert.is_false(state.show)
- assert.is_false(state.enabled)
+ assert.is_false(state.showSecretNameWarning)
+ assert.is_true(state.hasRowsNeedingReconcile)
+ assert.is_true(state.canReconcile)
+ assert.are.equal("", state.warningText)
end)
- it("_GetSecretNameFooterState shows an enabled footer for unlabeled bars outside restricted areas", function()
- local state = BuffBarsOptions._GetSecretNameFooterState({
+ it("_GetSpellColorsPageState shows the secret-name warning for unlabeled bars", function()
+ local state = BuffBarsOptions._GetSpellColorsPageState({
{ key = { primaryKey = "" } },
})
- assert.is_true(state.show)
- assert.is_true(state.enabled)
+ assert.is_true(state.showSecretNameWarning)
end)
- it("_GetSecretNameFooterState disables reload in instances", function()
+ it("_GetSpellColorsPageState disables reconcile in instances", function()
_G.IsInInstance = function()
return true, "party"
end
- local state = BuffBarsOptions._GetSecretNameFooterState({
- { key = { primaryKey = "" } },
+ local state = BuffBarsOptions._GetSpellColorsPageState({
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
})
- assert.is_true(state.show)
- assert.is_false(state.enabled)
+ assert.is_false(state.canReconcile)
end)
- it("_GetSecretNameFooterState disables reload during combat", function()
- _G.InCombatLockdown = function()
+ it("_GetSpellColorsPageState disables reconcile when the player is in combat", function()
+ _G.UnitAffectingCombat = function()
return true
end
- _G.issecretvalue = function(value)
- return value == "Secret Spell"
+
+ local state = BuffBarsOptions._GetSpellColorsPageState({
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
+ })
+
+ assert.is_false(state.canReconcile)
+ end)
+
+ it("_GetSpellColorsPageState disables reconcile during combat lockdown", function()
+ _G.InCombatLockdown = function()
+ return true
end
- local state = BuffBarsOptions._GetSecretNameFooterState({
- { key = { primaryKey = "Secret Spell" } },
+ local state = BuffBarsOptions._GetSpellColorsPageState({
+ { key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
})
- assert.is_true(state.show)
- assert.is_false(state.enabled)
+ assert.is_false(state.canReconcile)
+ assert.are.equal(ns.L["SPELL_COLORS_COMBAT_WARNING"], state.warningText)
end)
it("_BuildSpellColorKeyTooltipLines includes every available key", function()
@@ -300,70 +311,47 @@ describe("BuffBarsOptions", function()
}, lines)
end)
- it("_HasRowsNeedingReconcile detects rows missing any identifying key", function()
- assert.is_false(BuffBarsOptions._HasRowsNeedingReconcile({
+ it("_GetSpellColorsPageState detects rows missing any identifying key", function()
+ assert.is_false(BuffBarsOptions._GetSpellColorsPageState({
{ key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001) },
- }))
+ }).hasRowsNeedingReconcile)
- assert.is_true(BuffBarsOptions._HasRowsNeedingReconcile({
+ assert.is_true(BuffBarsOptions._GetSpellColorsPageState({
{ key = SpellColors.MakeKey("Immolation Aura", 258920, nil, nil) },
- }))
+ }).hasRowsNeedingReconcile)
- assert.is_true(BuffBarsOptions._HasRowsNeedingReconcile({
+ assert.is_true(BuffBarsOptions._GetSpellColorsPageState({
{ key = SpellColors.MakeKey(nil, 258920, 77, 9001) },
- }))
+ }).hasRowsNeedingReconcile)
end)
local function registerSpellColorsSpec()
- local spellColorsSpec
- local buttonSpecs = {}
+ local spellColorsSpec = assert(BuffBarsOptions.pages[2])
local refreshCalls = {}
- local originalRegisterPage = SB.RegisterPage
- local originalButton = SB.Button
+ local fakePage = {
+ Refresh = function()
+ refreshCalls[#refreshCalls + 1] = spellColorsSpec.name
+ end,
+ }
- SB.RegisterPage = function(page)
- if page.name == ns.L["SPELL_COLORS_SUBCAT"] then
- spellColorsSpec = page
- end
- return originalRegisterPage(page)
- end
- SB.Button = function(spec)
- buttonSpecs[#buttonSpecs + 1] = spec
- return originalButton(spec)
- end
- SB.RefreshCategory = function(category)
- refreshCalls[#refreshCalls + 1] = category
+ if spellColorsSpec.onRegistered then
+ spellColorsSpec.onRegistered(fakePage)
end
- BuffBarsOptions.RegisterSettings(SB)
-
- SB.RegisterPage = originalRegisterPage
- SB.Button = originalButton
-
- return assert(spellColorsSpec), refreshCalls, buttonSpecs
+ return spellColorsSpec, refreshCalls
end
it("does not add the old configure spell colors shortcut to aura bars", function()
- local _, _, buttonSpecs = registerSpellColorsSpec()
-
- local sawLayoutButton = false
- local sawReloadButton = false
-
- assert.are.equal(2, #buttonSpecs)
-
- for _, spec in ipairs(buttonSpecs) do
- assert.are_not.equal("Configure Spell Colors", spec.name)
-
- if spec.name == ns.L["LAYOUT_SUBCATEGORY"] then
- sawLayoutButton = true
- assert.are.equal(ns.L["LAYOUT_PAGE_MOVED_BUTTON_TEXT"], spec.buttonText)
- elseif spec.buttonText == ns.L["SPELL_COLORS_RELOAD_BUTTON"] then
- sawReloadButton = true
+ local buttonRows = {}
+ for _, row in ipairs(BuffBarsOptions.pages[1].rows) do
+ if row.type == "button" then
+ buttonRows[#buttonRows + 1] = row
end
end
- assert.is_true(sawLayoutButton)
- assert.is_true(sawReloadButton)
+ assert.are.equal(1, #buttonRows)
+ assert.are.equal(ns.L["LAYOUT_SUBCATEGORY"], buttonRows[1].name)
+ assert.are.equal(ns.L["LAYOUT_PAGE_MOVED_BUTTON_TEXT"], buttonRows[1].buttonText)
end)
it("ctrl-hovering a spell color collection row shows all keys for that row", function()
diff --git a/Tests/UI/BuffBarsSettingsOptions_spec.lua b/Tests/UI/BuffBarsSettingsOptions_spec.lua
index 06561f92..8a68a241 100644
--- a/Tests/UI/BuffBarsSettingsOptions_spec.lua
+++ b/Tests/UI/BuffBarsSettingsOptions_spec.lua
@@ -51,17 +51,10 @@ describe("BuffBarsOptions settings getters/setters/defaults", function()
}
ns.Addon.ConfirmReloadUI = function(_, _, cb) if cb then cb() end end
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- if page.name == ns.L["AURA_BARS"] then
- capturedPage = page
- end
- return originalRegisterPage(page)
- end
-
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/BuffBarsOptions.lua", "BuffBarsOptions")(nil, ns)
- ns.OptionsSections.BuffBars.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.BuffBarsOptions)
+ capturedPage = ns.BuffBarsOptions.pages[1]
end)
assert.is_not_nil(capturedPage)
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 6d08edd1..0cb1af6b 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -43,7 +43,6 @@ describe("ExtraIconsOptions data helpers", function()
CreateModuleEnabledHandler = function() return function() end end,
MakeConfirmDialog = function() return {} end,
}
- ns.SettingsBuilder = { RegisterSection = function(_, _, section) ns.ExtraIconsOptions = section end }
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ExtraIconsOptions = ns.ExtraIconsOptions
end)
@@ -752,7 +751,7 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns, capturedPage, refreshCalls, scheduledReasons, previewCalls
+ local profile, defaults, SB, ns, capturedPage, registeredPage, refreshCalls, scheduledReasons, previewCalls
local function getRow(rowId)
local rows = assert(capturedPage and capturedPage.rows)
@@ -841,21 +840,17 @@ describe("ExtraIconsOptions settings page", function()
previewCalls[#previewCalls + 1] = active
end
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- capturedPage = page
- return originalRegisterPage(page)
- end
- SB.RefreshCategory = function(category)
- refreshCalls[#refreshCalls + 1] = category
- end
-
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
- ns.OptionsSections.ExtraIcons.RegisterSettings(SB)
+ capturedPage = ns.ExtraIconsOptions
+ local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
+ registeredPage = page
+ registeredPage.Refresh = function()
+ refreshCalls[#refreshCalls + 1] = registeredPage._category
+ end
end)
- it("creates a subcategory", function()
- assert.is_not_nil(SB.GetSubcategory(ns.L["EXTRA_ICONS"]))
+ it("registers a page category", function()
+ assert.is_not_nil(registeredPage._category)
end)
it("registers page-level onShow and onHide callbacks", function()
@@ -993,7 +988,7 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal(1, #profile.extraIcons.viewers.main)
assert.are.equal("spell", profile.extraIcons.viewers.main[1].kind)
assert.are.same({ 12345 }, profile.extraIcons.viewers.main[1].ids)
- local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
+ local category = registeredPage._category
assert.are.same({ category }, refreshCalls)
assert.are.same({ "OptionsChanged" }, scheduledReasons)
end)
@@ -1042,7 +1037,7 @@ describe("ExtraIconsOptions settings page", function()
end)
it("refreshes the category when trinket equipment changes", function()
- local category = SB.GetSubcategory(ns.L["EXTRA_ICONS"])
+ local category = registeredPage._category
local eventHandler = ns.ExtraIconsOptions._itemLoadFrame:GetScript("OnEvent")
eventHandler(ns.ExtraIconsOptions._itemLoadFrame, "PLAYER_EQUIPMENT_CHANGED", 13, true)
diff --git a/Tests/UI/GeneralOptions_spec.lua b/Tests/UI/GeneralOptions_spec.lua
index bd53e64c..a458f6cb 100644
--- a/Tests/UI/GeneralOptions_spec.lua
+++ b/Tests/UI/GeneralOptions_spec.lua
@@ -26,7 +26,7 @@ describe("GeneralOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
- ns.OptionsSections.General.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.GeneralOptions)
end)
end)
diff --git a/Tests/UI/LayoutOptions_spec.lua b/Tests/UI/LayoutOptions_spec.lua
index 9d75dd47..a18f5d28 100644
--- a/Tests/UI/LayoutOptions_spec.lua
+++ b/Tests/UI/LayoutOptions_spec.lua
@@ -28,15 +28,10 @@ describe("LayoutOptions getters/setters/defaults", function()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- capturedPage = page
- return originalRegisterPage(page)
- end
-
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/LayoutOptions.lua", "LayoutOptions")(nil, ns)
- ns.OptionsSections.Layout.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.LayoutOptions)
+ capturedPage = ns.LayoutOptions
end)
end)
@@ -102,9 +97,16 @@ describe("LayoutOptions getters/setters/defaults", function()
end
_G.Settings = proxiedSettings
- TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
- ns.OptionsSections.General.RegisterSettings(SB)
- ns.OptionsSections.Layout.RegisterSettings(SB)
+ local profile2, defaults2 = TestHelpers.MakeOptionsProfile()
+ local SB2, ns2 = TestHelpers.SetupOptionsEnv(profile2, defaults2)
+ TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns2)
+ TestHelpers.LoadChunk("UI/LayoutOptions.lua", "LayoutOptions")(nil, ns2)
+ TestHelpers.RegisterSettingsTree(SB2, {
+ sections = {
+ ns2.GeneralOptions,
+ ns2.LayoutOptions,
+ },
+ })
_G.Settings = originalSettings
diff --git a/Tests/UI/OptionsSections_spec.lua b/Tests/UI/OptionsSections_spec.lua
index be242c8a..2afdd4c4 100644
--- a/Tests/UI/OptionsSections_spec.lua
+++ b/Tests/UI/OptionsSections_spec.lua
@@ -1,11 +1,7 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
local TestHelpers =
assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
-describe("Options sections and root assembly", function()
+describe("Options root assembly", function()
local originalGlobals
setup(function()
@@ -34,7 +30,7 @@ describe("Options sections and root assembly", function()
TestHelpers.RestoreGlobals(originalGlobals)
end)
- it("root Options module creates categories and calls RegisterSettings on sections", function()
+ it("OnInitialize builds one root page and all declarative sections", function()
TestHelpers.SetupOptionsGlobals()
local lsmw = TestHelpers.SetupLibSettingsBuilder()
lsmw.GetFontValues = function()
@@ -44,247 +40,99 @@ describe("Options sections and root assembly", function()
return {}
end
- local registerSettingsCalls = {}
- local dbCallbacks = {}
-
+ local createdModule
local ns = {
- Constants = {
- ANCHORMODE_CHAIN = 1,
- ANCHORMODE_FREE = 2,
- DEFAULT_BAR_WIDTH = 300,
+ Runtime = {
+ ScheduleLayoutUpdate = function() end,
+ },
+ ColorUtil = {
+ Sparkle = function(text)
+ return text
+ end,
},
- L = setmetatable({}, { __index = function(_, k) return k end }),
- ScheduleLayoutUpdate = function() end,
- OptionsSections = {},
}
- _G.ECM_DeepEquals = TestHelpers.deepEquals
+ TestHelpers.LoadLiveConstants(ns)
ns.CloneValue = function(v)
return v
end
+ ns.Constants.ANCHORMODE_CHAIN = 1
+ ns.Constants.ANCHORMODE_FREE = 2
+ ns.Constants.ANCHORMODE_DETACHED = 3
- _G.UnitClass = function()
- return "Warrior", "WARRIOR", 1
- end
- _G.GetSpecialization = function()
- return 1
- end
- _G.GetSpecializationInfo = function()
- return nil, "Arms"
- end
-
- local createdModule
- local mod = {
+ ns.Addon = {
db = {
profile = {},
defaults = { profile = {} },
- RegisterCallback = function(_, _, eventName, methodName)
- dbCallbacks[#dbCallbacks + 1] = { eventName = eventName, methodName = methodName }
- end,
+ RegisterCallback = function() end,
},
- NewModule = function(self, name)
+ NewModule = function(_, name)
createdModule = { moduleName = name }
return createdModule
end,
}
- ns.Addon = mod
+ TestHelpers.LoadChunk("UI/OptionUtil.lua", "OptionUtil")(nil, ns)
+ TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
- for _, key in ipairs({
- "About",
- "General",
- "Layout",
- "PowerBar",
- "ResourceBar",
- "RuneBar",
- "BuffBars",
- "ExtraIcons",
- "Profile",
- "Advanced Options",
- }) do
- ns.OptionsSections[key] = {
- RegisterSettings = function()
- registerSettingsCalls[#registerSettingsCalls + 1] = key
- end,
+ local function placeholderSection(key, name)
+ return {
+ key = key,
+ name = name,
+ rows = {},
}
end
- TestHelpers.LoadChunk("UI/OptionUtil.lua", "OptionUtil")(nil, ns)
- TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
-
- ns.OptionsSections["About"] = {
- RegisterSettings = function()
- registerSettingsCalls[#registerSettingsCalls + 1] = "About"
- end,
- }
+ ns.GeneralOptions = placeholderSection("general", ns.L["GENERAL"])
+ ns.LayoutOptions = placeholderSection("layout", ns.L["LAYOUT_SUBCATEGORY"])
+ ns.PowerBarOptions = placeholderSection("powerBar", ns.L["POWER_BAR"])
+ ns.ResourceBarOptions = placeholderSection("resourceBar", ns.L["RESOURCE_BAR"])
+ ns.RuneBarOptions = placeholderSection("runeBar", ns.L["RUNE_BAR"])
+ ns.BuffBarsOptions = placeholderSection("buffBars", ns.L["AURA_BARS"])
+ ns.ExtraIconsOptions = placeholderSection("extraIcons", ns.L["EXTRA_ICONS"])
+ ns.ProfileOptions = placeholderSection("profile", ns.L["PROFILES"])
+ ns.AdvancedOptions = placeholderSection("advancedOptions", ns.L["ADVANCED_OPTIONS"])
assert.is_table(createdModule)
createdModule:OnInitialize()
- assert.are.same({
- "About",
- "General",
- "Layout",
- "PowerBar",
- "ResourceBar",
- "RuneBar",
- "BuffBars",
- "ExtraIcons",
- "Profile",
- "Advanced Options",
- }, registerSettingsCalls)
- assert.are.equal(0, #dbCallbacks)
- assert.is_not_nil(ns.SettingsBuilder.GetRootCategoryID())
- end)
+ assert.is_table(ns.Settings)
+ assert.are.equal(ns.L["ADDON_NAME"], ns.Settings.name)
+ assert.is_not_nil(ns.Settings:GetPage("about"))
- it("general option pages register canonical rows through RegisterPage", function()
- TestHelpers.SetupOptionsGlobals()
- local lsmw = TestHelpers.SetupLibSettingsBuilder()
- lsmw.GetFontValues = function()
- return {}
- end
- lsmw.GetStatusbarValues = function()
- return {}
+ for _, key in ipairs({
+ "general",
+ "layout",
+ "powerBar",
+ "resourceBar",
+ "runeBar",
+ "buffBars",
+ "extraIcons",
+ "profile",
+ "advancedOptions",
+ }) do
+ assert.is_not_nil(ns.Settings:GetSection(key), "missing registered section: " .. key)
end
-
- local ns = {
- Addon = {
- db = {
- profile = {},
- defaults = { profile = {} },
- },
- NewModule = function(_, name)
- return { moduleName = name }
- end,
- },
- OptionsSections = {},
- Runtime = {
- ScheduleLayoutUpdate = function() end,
- },
- }
-
- TestHelpers.LoadLiveConstants(ns)
-
- TestHelpers.LoadChunk("UI/OptionUtil.lua", "OptionUtil")(nil, ns)
- TestHelpers.LoadChunk("UI/Options.lua", "Options")(nil, ns)
- TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
-
- local capturedPages = {}
- local captureSB = {
- RegisterPage = function(page)
- capturedPages[#capturedPages + 1] = page
- end,
- }
-
- ns.OptionsSections.General.RegisterSettings(captureSB)
- ns.OptionsSections["Advanced Options"].RegisterSettings(captureSB)
-
- local generalPage = capturedPages[1]
- assert.is_table(generalPage)
- assert.are.equal(ns.L["GENERAL"], generalPage.name)
- assert.are.equal("global", generalPage.path)
- assert.are.equal(16, #generalPage.rows)
- assert.are.equal("header", generalPage.rows[1].type)
- assert.are.equal("checkbox", generalPage.rows[2].type)
- assert.are.equal("checkbox", generalPage.rows[4].type)
- assert.are.equal("fade", generalPage.rows[4].id)
- assert.are.equal("slider", generalPage.rows[5].type)
- assert.are.equal("fade", generalPage.rows[5].parent)
- assert.are.equal("dropdown", generalPage.rows[13].type)
- assert.are.equal("slider", generalPage.rows[16].type)
-
- local advancedPage = capturedPages[2]
- assert.is_table(advancedPage)
- assert.are.equal(ns.L["ADVANCED_OPTIONS"], advancedPage.name)
- assert.are.equal("global", advancedPage.path)
- assert.are.equal(7, #advancedPage.rows)
- assert.are.equal("header", advancedPage.rows[1].type)
- assert.are.equal("checkbox", advancedPage.rows[2].type)
- assert.are.equal("checkbox", advancedPage.rows[3].type)
- assert.are.equal("button", advancedPage.rows[5].type)
- assert.are.equal("slider", advancedPage.rows[7].type)
end)
- it("resource/rune sections register via SB.RegisterSection and have class gating", function()
+ it("General and Advanced options export declarative section specs", function()
TestHelpers.SetupOptionsGlobals()
- local lsmw = TestHelpers.SetupLibSettingsBuilder()
- lsmw.GetFontValues = function()
- return {}
- end
- lsmw.GetStatusbarValues = function()
- return {}
- end
-
- _G.UnitClass = function()
- return "Player", "WARRIOR", 1
- end
- _G.GetSpecialization = function()
- return 1
- end
- _G.GetSpecializationInfo = function()
- return nil, "Arms"
- end
-
- _G.Enum = {
- PowerType = {
- ArcaneCharges = 1,
- Chi = 2,
- ComboPoints = 3,
- Essence = 4,
- HolyPower = 5,
- SoulShards = 6,
- },
- }
+ local profile, defaults = TestHelpers.MakeOptionsProfile()
+ local _, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- local ns = {
- Addon = {
- db = {
- profile = {},
- defaults = { profile = {} },
- },
- NewModule = function(_, name)
- return { moduleName = name }
- end,
- },
- OptionsSections = {},
- }
-
- TestHelpers.LoadLiveConstants(ns)
- -- Test-specific sentinel values
- ns.Constants.ANCHORMODE_CHAIN = 1
- ns.Constants.ANCHORMODE_FREE = 2
- ns.Constants.DEFAULT_BAR_WIDTH = 300
- ns.Runtime = ns.Runtime or {}
- ns.Runtime.ScheduleLayoutUpdate = function() end
-
- local border = { enabled = false, thickness = 1, color = { r = 0, g = 0, b = 0, a = 1 } }
- local profileData = {
- resourceBar = { enabled = true, anchorMode = 1, border = border },
- runeBar = {
- enabled = true,
- useSpecColor = false,
- anchorMode = 1,
- border = TestHelpers.deepClone(border),
- color = { r = 0.77, g = 0.12, b = 0.23, a = 1 },
- colorBlood = { r = 0.87, g = 0.10, b = 0.22, a = 1 },
- colorFrost = { r = 0.33, g = 0.69, b = 0.87, a = 1 },
- colorUnholy = { r = 0, g = 0.61, b = 0, a = 1 },
- },
- }
-
- ns.Addon.db.profile = profileData
- ns.Addon.db.defaults = { profile = TestHelpers.deepClone(profileData) }
-
- TestHelpers.LoadChunk("UI/OptionUtil.lua", "OptionUtil")(nil, ns)
- TestHelpers.LoadChunk("UI/Options.lua", "Options")(nil, ns)
- ns.SettingsBuilder.CreateRootCategory("Test")
-
- TestHelpers.LoadChunk("UI/ResourceBarOptions.lua", "ResourceBarOptions")(nil, ns)
- TestHelpers.LoadChunk("UI/RuneBarOptions.lua", "RuneBarOptions")(nil, ns)
+ TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
- assert.is_not_nil(ns.OptionsSections.ResourceBar)
- assert.is_not_nil(ns.OptionsSections.RuneBar)
- assert.is_function(ns.OptionsSections.ResourceBar.RegisterSettings)
- assert.is_function(ns.OptionsSections.RuneBar.RegisterSettings)
+ assert.are.equal("general", ns.GeneralOptions.key)
+ assert.are.equal(ns.L["GENERAL"], ns.GeneralOptions.name)
+ assert.are.equal("global", ns.GeneralOptions.path)
+ assert.are.equal(16, #ns.GeneralOptions.rows)
+ assert.are.equal("header", ns.GeneralOptions.rows[1].type)
+ assert.are.equal("slider", ns.GeneralOptions.rows[16].type)
+
+ assert.are.equal("advancedOptions", ns.AdvancedOptions.key)
+ assert.are.equal(ns.L["ADVANCED_OPTIONS"], ns.AdvancedOptions.name)
+ assert.are.equal("global", ns.AdvancedOptions.path)
+ assert.are.equal(7, #ns.AdvancedOptions.rows)
+ assert.are.equal("button", ns.AdvancedOptions.rows[5].type)
end)
end)
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index 08bf4c8b..f2844d5e 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -62,7 +62,6 @@ describe("OptionUtil", function()
DisableModule = function() end,
ConfirmReloadUI = function() end,
},
- OptionsSections = {},
}
ns.ColorUtil = {
Sparkle = function(text)
@@ -88,26 +87,25 @@ describe("OptionUtil", function()
optionsModule = ns.Addon._modules.Options
end)
- describe("About section RegisterSettings", function()
+ describe("About page spec", function()
it("registers the root About page with ordered rows", function()
- local registeredPage
- ns.OptionsSections["About"].RegisterSettings({
- RegisterPage = function(page)
- registeredPage = page
- end,
- })
+ local _, registeredPage = TestHelpers.RegisterRootPageSpec(
+ ns.SettingsBuilder,
+ ns.AboutPage,
+ ns.L["ADDON_NAME"]
+ )
+ local rows = ns.AboutPage.rows
assert.is_table(registeredPage)
- assert.are.equal(ns.L["ADDON_NAME"], registeredPage.name)
- assert.is_true(registeredPage.rootCategory)
- assert.are.equal(6, #registeredPage.rows)
- assert.are.equal("info", registeredPage.rows[1].type)
- assert.are.equal("info", registeredPage.rows[2].type)
- assert.are.equal("info", registeredPage.rows[3].type)
- assert.are.equal("subheader", registeredPage.rows[4].type)
- assert.are.equal(ns.L["LINKS"], registeredPage.rows[4].name)
- assert.are.equal("button", registeredPage.rows[5].type)
- assert.are.equal("button", registeredPage.rows[6].type)
+ assert.are.equal(ns.L["ADDON_NAME"], registeredPage:GetID())
+ assert.are.equal(6, #rows)
+ assert.are.equal("info", rows[1].type)
+ assert.are.equal("info", rows[2].type)
+ assert.are.equal("info", rows[3].type)
+ assert.are.equal("subheader", rows[4].type)
+ assert.are.equal(ns.L["LINKS"], rows[4].name)
+ assert.are.equal("button", rows[5].type)
+ assert.are.equal("button", rows[6].type)
end)
end)
@@ -352,21 +350,27 @@ describe("OptionUtil", function()
openedCategory = categoryID
end)
- ns.OptionsSections["About"] = {
- RegisterSettings = function() end,
- }
- ns.OptionsSections["General"] = {
- RegisterSettings = function(SB)
- generalCategory = SB.CreateSubcategory(ns.L["GENERAL"])
- end,
- }
- ns.OptionsSections["Profile"] = {
- RegisterSettings = function(SB)
- profileCategory = SB.CreateSubcategory(ns.L["PROFILES"])
- end,
- }
+ local function placeholderSection(key, name)
+ return {
+ key = key,
+ name = name,
+ rows = {},
+ }
+ end
+
+ ns.GeneralOptions = placeholderSection("general", ns.L["GENERAL"])
+ ns.LayoutOptions = placeholderSection("layout", ns.L["LAYOUT_SUBCATEGORY"])
+ ns.PowerBarOptions = placeholderSection("powerBar", ns.L["POWER_BAR"])
+ ns.ResourceBarOptions = placeholderSection("resourceBar", ns.L["RESOURCE_BAR"])
+ ns.RuneBarOptions = placeholderSection("runeBar", ns.L["RUNE_BAR"])
+ ns.BuffBarsOptions = placeholderSection("buffBars", ns.L["AURA_BARS"])
+ ns.ExtraIconsOptions = placeholderSection("extraIcons", ns.L["EXTRA_ICONS"])
+ ns.ProfileOptions = placeholderSection("profile", ns.L["PROFILES"])
+ ns.AdvancedOptions = placeholderSection("advancedOptions", ns.L["ADVANCED_OPTIONS"])
optionsModule:OnInitialize()
+ generalCategory = ns.Settings:GetSection("general"):GetPage("main")._category
+ profileCategory = ns.Settings:GetSection("profile"):GetPage("main")._category
end)
it("opens General when no ECM page has been visited yet", function()
diff --git a/Tests/UI/PowerBarOptions_spec.lua b/Tests/UI/PowerBarOptions_spec.lua
index 061cf868..17c34b6b 100644
--- a/Tests/UI/PowerBarOptions_spec.lua
+++ b/Tests/UI/PowerBarOptions_spec.lua
@@ -24,17 +24,12 @@ describe("PowerBarOptions getters/setters/defaults", function()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- ns.PowerBarTickMarksOptions = { RegisterSettings = function() end }
-
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- capturedPage = page
- return originalRegisterPage(page)
- end
+ ns.PowerBarTickMarksOptions = { key = "tickMarks", name = "Tick Marks", rows = {} }
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/PowerBarOptions.lua", "PowerBarOptions")(nil, ns)
- ns.OptionsSections.PowerBar.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.PowerBarOptions)
+ capturedPage = ns.PowerBarOptions.pages[1]
end)
end)
@@ -163,16 +158,19 @@ describe("PowerBarOptions getters/setters/defaults", function()
describe("tick marks menu placement", function()
it("registers Tick Marks as a subcategory of Power Bar", function()
- TestHelpers.LoadChunk("UI/PowerBarTickMarksOptions.lua", "PowerBarTickMarksOptions")(nil, ns)
+ local profile2, defaults2 = TestHelpers.MakeOptionsProfile()
+ local SB2, ns2 = TestHelpers.SetupOptionsEnv(profile2, defaults2)
- ns.OptionsSections.PowerBar.RegisterSettings(SB)
+ TestHelpers.LoadChunk("UI/PowerBarTickMarksOptions.lua", "PowerBarTickMarksOptions")(nil, ns2)
+ TestHelpers.LoadChunk("UI/PowerBarOptions.lua", "PowerBarOptions")(nil, ns2)
- local powerBarCategory = SB._subcategories[ns.L["POWER_BAR"]]
- local tickMarksCategory = SB._subcategories["Tick Marks"]
+ local _, section = TestHelpers.RegisterSectionSpec(SB2, ns2.PowerBarOptions)
+ local powerBarSectionCategory = section:GetPage("main")._category._parent
+ local tickMarksCategory = section:GetPage("tickMarks")._category
- assert.is_not_nil(powerBarCategory)
+ assert.is_not_nil(powerBarSectionCategory)
assert.is_not_nil(tickMarksCategory)
- assert.are.equal(powerBarCategory, tickMarksCategory._parent)
+ assert.are.equal(powerBarSectionCategory, tickMarksCategory._parent)
end)
end)
end)
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index e43dca19..f1807123 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -9,6 +9,8 @@ local TestHelpers = assert(
describe("PowerBarTickMarksOptions", function()
local originalGlobals
+ local currentClassID
+ local currentSpecIndex
local ns
local function getRow(page, rowId)
@@ -19,28 +21,30 @@ describe("PowerBarTickMarksOptions", function()
end
end
- local function registerSettings(parentCategory)
- local captured
- local refreshCalls = {}
- local fakeCategory = {}
+ local function setTickMappings(mappings)
+ ns.Addon.db.profile.powerBar = {
+ ticks = {
+ mappings = mappings,
+ defaultColor = ns.Constants.DEFAULT_POWERBAR_TICK_COLOR,
+ defaultWidth = 1,
+ },
+ }
+ end
- local SB = {
- RegisterPage = function(page)
- captured = page
- end,
- GetSubcategory = function(name)
- if name == "Tick Marks" then
- return fakeCategory
- end
- end,
- RefreshCategory = function(category)
- refreshCalls[#refreshCalls + 1] = category
+ local function registerPageSpec()
+ local captured = assert(ns.PowerBarTickMarksOptions)
+ local refreshCalls = {}
+ local fakePage = {
+ Refresh = function()
+ refreshCalls[#refreshCalls + 1] = true
end,
}
- ns.PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
+ if captured.onRegistered then
+ captured.onRegistered(fakePage)
+ end
- return captured, refreshCalls, fakeCategory
+ return captured, refreshCalls, fakePage
end
setup(function()
@@ -58,30 +62,44 @@ describe("PowerBarTickMarksOptions", function()
end)
before_each(function()
- ns = TestHelpers.SetupPowerBarTickMarksEnv()
+ currentClassID = 1
+ currentSpecIndex = 2
+
+ ns = TestHelpers.SetupPowerBarTickMarksEnv({
+ getCurrentClassSpec = function()
+ return currentClassID, currentSpecIndex, "Warrior", "Fury", "WARRIOR"
+ end,
+ })
end)
- it("module loads and exposes RegisterSettings and Store", function()
+ it("module loads and exposes only the page spec", function()
assert.is_table(ns.PowerBarTickMarksOptions)
- assert.is_function(ns.PowerBarTickMarksOptions.RegisterSettings)
-
- assert.is_table(ns.PowerBarTickMarksStore)
- assert.is_function(ns.PowerBarTickMarksStore.GetCurrentTicks)
- assert.is_function(ns.PowerBarTickMarksStore.AddTick)
+ assert.are.equal("tickMarks", ns.PowerBarTickMarksOptions.key)
+ assert.is_nil(ns.PowerBarTickMarksStore)
end)
- it("registers a subcategory with page actions and list-based tick editors", function()
- local parentCategory = {}
- local captured = registerSettings(parentCategory)
+ it("exports a page with page actions and list-based tick editors", function()
+ local captured = registerPageSpec()
assert.are.equal("Tick Marks", captured.name)
- assert.are.equal(parentCategory, captured.parentCategory)
assert.are.equal("pageActions", getRow(captured, "tickMarksPageActions").type)
assert.are.equal("list", getRow(captured, "tickCollection").type)
assert.are.equal("editor", getRow(captured, "tickCollection").variant)
assert.are.equal(320, getRow(captured, "tickCollection").height)
end)
+ it("shows an empty collection when class/spec is unavailable", function()
+ currentClassID = nil
+ currentSpecIndex = nil
+
+ local captured = registerPageSpec()
+ local defaultsAction = getRow(captured, "tickMarksPageActions").actions[1]
+ local tickCollection = getRow(captured, "tickCollection")
+
+ assert.are.same({}, tickCollection.items())
+ assert.is_false(defaultsAction.enabled())
+ end)
+
it("add button appends a tick using the current defaults", function()
local scheduledReason
ns.Runtime = {
@@ -90,23 +108,41 @@ describe("PowerBarTickMarksOptions", function()
end,
}
- local captured, refreshCalls, fakeCategory = registerSettings({})
+ local captured, refreshCalls, fakePage = registerPageSpec()
+ local defaultColor = { r = 0.1, g = 0.2, b = 0.3, a = 0.4 }
+ local tickCollection = getRow(captured, "tickCollection")
- getRow(captured, "addTick").onClick()
+ getRow(captured, "defaultWidth").set(3)
+ getRow(captured, "defaultColor").set(defaultColor)
- local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.equal(1, #ticks)
- assert.are.equal(50, ticks[1].value)
- assert.are.equal(ns.PowerBarTickMarksStore.GetDefaultWidth(), ticks[1].width)
- assert.are.same(ns.PowerBarTickMarksStore.GetDefaultColor(), ticks[1].color)
+ getRow(captured, "addTick").onClick(fakePage)
+
+ local items = tickCollection.items()
+ assert.are.equal(1, #items)
+ assert.are.equal(50, items[1].fields[1].value)
+ assert.are.equal(3, items[1].fields[2].value)
+ assert.are.same(defaultColor, items[1].color.value)
assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ fakeCategory }, refreshCalls)
+ assert.are.same({ true }, refreshCalls)
end)
- it("defaults action clears the current spec ticks after confirmation", function()
+ it("defaults action clears only the current spec ticks after confirmation", function()
local shownPopup
local scheduledReason
+ setTickMappings({
+ [1] = {
+ [2] = {
+ { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
+ },
+ },
+ [2] = {
+ [1] = {
+ { value = 30, width = 1, color = { r = 0, g = 1, b = 0, a = 1 } },
+ },
+ },
+ })
+
_G.StaticPopup_Show = function(name, _, _, data)
shownPopup = name
local dialog = _G.StaticPopupDialogs[name]
@@ -117,55 +153,96 @@ describe("PowerBarTickMarksOptions", function()
scheduledReason = reason
end,
}
- ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
- })
- local captured, refreshCalls, fakeCategory = registerSettings({})
+ local captured, refreshCalls = registerPageSpec()
local defaultsAction = getRow(captured, "tickMarksPageActions").actions[1]
+ local tickCollection = getRow(captured, "tickCollection")
defaultsAction.onClick()
assert.are.equal("ECM_CONFIRM_CLEAR_TICKS", shownPopup)
- assert.are.same({}, ns.PowerBarTickMarksStore.GetCurrentTicks())
+ assert.are.same({}, tickCollection.items())
+
+ currentClassID = 2
+ currentSpecIndex = 1
+
+ local otherSpecItems = tickCollection.items()
+ assert.are.equal(1, #otherSpecItems)
+ assert.are.equal(30, otherSpecItems[1].fields[1].value)
assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ fakeCategory }, refreshCalls)
+ assert.are.same({ true }, refreshCalls)
end)
- it("collection editor callbacks update tick values, widths, and removal", function()
+ it("collection editor callbacks update color, values, widths, and removal without touching another spec", function()
local scheduledReasons = {}
+ local pickedColor = { r = 0.25, g = 0.5, b = 0.75, a = 1 }
+
+ setTickMappings({
+ [1] = {
+ [1] = {
+ { value = 20, width = 1, color = { r = 0.5, g = 0.5, b = 0.5, a = 1 } },
+ },
+ [2] = {
+ { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
+ { value = 80, width = 3, color = { r = 0, g = 0, b = 0, a = 1 } },
+ },
+ },
+ })
+
ns.Runtime = {
ScheduleLayoutUpdate = function(_, reason)
scheduledReasons[#scheduledReasons + 1] = reason
end,
}
- ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
- })
+ ns.OptionUtil.OpenColorPicker = function(current, withAlpha, onChanged)
+ assert.are.same({ r = 1, g = 1, b = 1, a = 1 }, current)
+ assert.is_true(withAlpha)
+ onChanged(pickedColor)
+ end
- local captured, refreshCalls = registerSettings({})
- local item = getRow(captured, "tickCollection").items()[1]
+ local captured, refreshCalls = registerPageSpec()
+ local tickCollection = getRow(captured, "tickCollection")
+ local item = tickCollection.items()[1]
+
+ item.color.onClick()
+ local items = tickCollection.items()
+ assert.are.same(pickedColor, items[1].color.value)
+
+ item = items[1]
item.fields[1].onValueChanged(75)
item.fields[2].onValueChanged(4)
- local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.equal(75, ticks[1].value)
- assert.are.equal(4, ticks[1].width)
+ items = tickCollection.items()
+ assert.are.equal(75, items[1].fields[1].value)
+ assert.are.equal(4, items[1].fields[2].value)
+
+ item = items[1]
item.remove.onClick()
- assert.are.same({}, ns.PowerBarTickMarksStore.GetCurrentTicks())
- assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
- assert.are.equal(3, #refreshCalls)
+ items = tickCollection.items()
+ assert.are.equal(1, #items)
+ assert.are.equal(80, items[1].fields[1].value)
+
+ currentSpecIndex = 1
+ items = tickCollection.items()
+ assert.are.equal(1, #items)
+ assert.are.equal(20, items[1].fields[1].value)
+ assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
+ assert.are.equal(4, #refreshCalls)
end)
it("rescales the value field range for large resource values", function()
- ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 50000, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
+ setTickMappings({
+ [1] = {
+ [2] = {
+ { value = 50000, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
+ },
+ },
})
- local captured = registerSettings({})
+ local captured = registerPageSpec()
local item = getRow(captured, "tickCollection").items()[1]
local minValue, maxValue, step = item.fields[1].getRange(item, 50000)
local nextMin, nextMax, nextStep = item.fields[1].getRange(item, 120000)
diff --git a/Tests/UI/PowerBarTickMarksStore_spec.lua b/Tests/UI/PowerBarTickMarksStore_spec.lua
deleted file mode 100644
index 008f36d1..00000000
--- a/Tests/UI/PowerBarTickMarksStore_spec.lua
+++ /dev/null
@@ -1,102 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local TestHelpers = assert(
- loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"),
- "Unable to load Tests/TestHelpers.lua"
-)()
-
-describe("PowerBarTickMarksStore", function()
- local originalGlobals
- local currentClassID
- local currentSpecIndex
- local ns
-
- setup(function()
- originalGlobals = TestHelpers.CaptureGlobals({})
- end)
-
- teardown(function()
- TestHelpers.RestoreGlobals(originalGlobals)
- end)
-
- before_each(function()
- currentClassID = 1
- currentSpecIndex = 2
-
- ns = TestHelpers.SetupPowerBarTickMarksEnv({
- constants = {
- DEFAULT_POWERBAR_TICK_COLOR = { r = 0.9, g = 0.8, b = 0.7, a = 0.6 },
- CLASS_COLORS = { WARRIOR = "C79C6E" },
- COLOR_WHITE_HEX = "FFFFFF",
- },
- getCurrentClassSpec = function()
- return currentClassID, currentSpecIndex, "Warrior", "Fury", "WARRIOR"
- end,
- })
- end)
-
- it("returns empty ticks when class/spec is unavailable", function()
- currentClassID = nil
- currentSpecIndex = nil
-
- local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.same({}, ticks)
- end)
-
- it("adds ticks using default color and width", function()
- ns.PowerBarTickMarksStore.SetDefaultWidth(3)
- ns.PowerBarTickMarksStore.SetDefaultColor({ r = 0.1, g = 0.2, b = 0.3, a = 0.4 })
-
- ns.PowerBarTickMarksStore.AddTick(50, nil, nil)
- local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
-
- assert.are.equal(1, #ticks)
- assert.are.equal(50, ticks[1].value)
- assert.are.same({ r = 0.1, g = 0.2, b = 0.3, a = 0.4 }, ticks[1].color)
- assert.are.equal(3, ticks[1].width)
- end)
-
- it("updates and removes ticks for the current spec only", function()
- ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 40, width = 1, color = { r = 1, g = 1, b = 1, a = 1 } },
- { value = 80, width = 2, color = { r = 0, g = 0, b = 0, a = 1 } },
- })
-
- currentSpecIndex = 1
- ns.PowerBarTickMarksStore.SetCurrentTicks({
- { value = 20, width = 1, color = { r = 0.5, g = 0.5, b = 0.5, a = 1 } },
- })
-
- currentSpecIndex = 2
- ns.PowerBarTickMarksStore.UpdateTick(1, "value", 45)
- ns.PowerBarTickMarksStore.RemoveTick(2)
-
- local spec2 = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.equal(1, #spec2)
- assert.are.equal(45, spec2[1].value)
-
- currentSpecIndex = 1
- local spec1 = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.equal(1, #spec1)
- assert.are.equal(20, spec1[1].value)
- end)
-
- it("clearing current ticks does not affect another class mapping", function()
- ns.PowerBarTickMarksStore.SetCurrentTicks({ { value = 10, width = 1, color = { r = 1, g = 0, b = 0, a = 1 } } })
-
- currentClassID = 2
- currentSpecIndex = 1
- ns.PowerBarTickMarksStore.SetCurrentTicks({ { value = 30, width = 2, color = { r = 0, g = 1, b = 0, a = 1 } } })
-
- ns.PowerBarTickMarksStore.SetCurrentTicks({})
- assert.are.equal(0, #ns.PowerBarTickMarksStore.GetCurrentTicks())
-
- currentClassID = 1
- currentSpecIndex = 2
- local ticks = ns.PowerBarTickMarksStore.GetCurrentTicks()
- assert.are.equal(1, #ticks)
- assert.are.equal(10, ticks[1].value)
- end)
-end)
diff --git a/Tests/UI/ProfileOptions_spec.lua b/Tests/UI/ProfileOptions_spec.lua
index b7b535ca..ae9c9485 100644
--- a/Tests/UI/ProfileOptions_spec.lua
+++ b/Tests/UI/ProfileOptions_spec.lua
@@ -23,10 +23,6 @@ describe("ProfileOptions getters/setters/defaults", function()
TestHelpers.SetupOptionsGlobals()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- refreshCalls = {}
- SB.RefreshCategory = function(category)
- refreshCalls[#refreshCalls + 1] = category
- end
-- Profile module needs import/export stubs
ns.ImportExport = {
@@ -38,9 +34,14 @@ describe("ProfileOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/ProfileOptions.lua", "ProfileOptions")(nil, ns)
- ns.OptionsSections.Profile.RegisterSettings(SB)
+ local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ProfileOptions)
+ profileCategory = page._category
end)
- profileCategory = SB._subcategories[ns.L["PROFILES"]]
+ refreshCalls = {}
+ local page = assert(SB:GetSection("profile"):GetPage("main"))
+ page.Refresh = function()
+ refreshCalls[#refreshCalls + 1] = profileCategory
+ end
initializers = SB._layouts[profileCategory]._initializers
end)
@@ -77,7 +78,7 @@ describe("ProfileOptions getters/setters/defaults", function()
assert.are.equal("ECM_NEW_PROFILE", getShown())
assert.are.equal("MyCustomProfile", switched)
- assert.are.same({ profileCategory, profileCategory }, refreshCalls)
+ assert.are.same({ profileCategory }, refreshCalls)
end)
end)
diff --git a/Tests/UI/ResourceBarOptions_spec.lua b/Tests/UI/ResourceBarOptions_spec.lua
index dbd256d9..6fca7caa 100644
--- a/Tests/UI/ResourceBarOptions_spec.lua
+++ b/Tests/UI/ResourceBarOptions_spec.lua
@@ -22,15 +22,10 @@ describe("ResourceBarOptions getters/setters/defaults", function()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- capturedPage = page
- return originalRegisterPage(page)
- end
-
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/ResourceBarOptions.lua", "ResourceBarOptions")(nil, ns)
- ns.OptionsSections.ResourceBar.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.ResourceBarOptions)
+ capturedPage = ns.ResourceBarOptions
end)
end)
@@ -242,7 +237,6 @@ describe("ResourceBarOptions class gating (DK)", function()
local _, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
TestHelpers.LoadChunk("UI/ResourceBarOptions.lua", "ResourceBarOptions")(nil, ns)
- assert.is_not_nil(ns.OptionsSections.ResourceBar)
- assert.is_function(ns.OptionsSections.ResourceBar.RegisterSettings)
+ assert.is_true(ns.ResourceBarOptions.disabled())
end)
end)
diff --git a/Tests/UI/RuneBarOptions_spec.lua b/Tests/UI/RuneBarOptions_spec.lua
index 4a34923f..a13adf28 100644
--- a/Tests/UI/RuneBarOptions_spec.lua
+++ b/Tests/UI/RuneBarOptions_spec.lua
@@ -26,15 +26,10 @@ describe("RuneBarOptions getters/setters/defaults", function()
profile, defaults = TestHelpers.MakeOptionsProfile()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
- local originalRegisterPage = SB.RegisterPage
- SB.RegisterPage = function(page)
- capturedPage = page
- return originalRegisterPage(page)
- end
-
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/RuneBarOptions.lua", "RuneBarOptions")(nil, ns)
- ns.OptionsSections.RuneBar.RegisterSettings(SB)
+ TestHelpers.RegisterSectionSpec(SB, ns.RuneBarOptions)
+ capturedPage = ns.RuneBarOptions
end)
end)
@@ -160,7 +155,6 @@ describe("RuneBarOptions class gating (non-DK)", function()
local _, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
TestHelpers.LoadChunk("UI/RuneBarOptions.lua", "RuneBarOptions")(nil, ns)
- assert.is_not_nil(ns.OptionsSections.RuneBar)
- assert.is_function(ns.OptionsSections.RuneBar.RegisterSettings)
+ assert.is_true(ns.RuneBarOptions.disabled())
end)
end)
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index e24a1042..3796d2bf 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -40,59 +40,58 @@ local function buildSpellColorRows(entries)
return rows
end
---- Scans rows for entries whose primary key is a secret or empty string.
----@param rows { key: ECM_SpellColorKey }[]
----@return boolean
-local function hasUnlabeledBars(rows)
- for _, row in ipairs(rows) do
- local key = row.key.primaryKey
- if type(key) == "string" and (issecretvalue(key) or key == "") then
- return true
- end
+---@param key ECM_SpellColorKey|table|nil
+---@return { hasSecretName: boolean, isIncomplete: boolean }|nil
+local function getSpellColorKeyState(key)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ local primaryKey = normalized and normalized.primaryKey or (type(key) == "table" and key.primaryKey)
+ if not normalized and type(primaryKey) ~= "string" then
+ return nil
end
- return false
-end
---- Returns whether the player is in an environment where secret-name recovery should stay disabled.
----@return boolean
-local function isSpellColorsReloadRestricted()
- return InCombatLockdown() or IsInInstance()
-end
-
---- Returns the footer state for the secret-name recovery controls.
----@param rows { key: ECM_SpellColorKey }[]
----@return { show: boolean, enabled: boolean }
-local function getSecretNameFooterState(rows)
- local show = hasUnlabeledBars(rows)
return {
- show = show,
- enabled = show and not isSpellColorsReloadRestricted(),
+ hasSecretName = type(primaryKey) == "string" and (issecretvalue(primaryKey) or primaryKey == ""),
+ isIncomplete = normalized ~= nil and (normalized.spellName == nil
+ or normalized.spellID == nil
+ or normalized.cooldownID == nil
+ or normalized.textureFileID == nil),
}
end
---- Returns true when the key is missing one or more identifying fields.
----@param key ECM_SpellColorKey|table|nil
---@return boolean
-local function isIncompleteSpellColorKey(key)
- local normalized = ns.SpellColors.NormalizeKey(key)
- return normalized ~= nil
- and (normalized.spellName == nil
- or normalized.spellID == nil
- or normalized.cooldownID == nil
- or normalized.textureFileID == nil)
+local function isSpellColorsReconcileRestricted()
+ return _G.UnitAffectingCombat("player") or InCombatLockdown() or IsInInstance()
end
---- Returns whether any row is missing one or more identifying fields.
---@param rows { key: ECM_SpellColorKey }[]|nil
----@return boolean
-local function hasRowsNeedingReconcile(rows)
+---@return { hasRowsNeedingReconcile: boolean, showSecretNameWarning: boolean, warningText: string, canReconcile: boolean }
+local function getSpellColorsPageState(rows)
+ local state = {
+ hasRowsNeedingReconcile = false,
+ showSecretNameWarning = false,
+ warningText = "",
+ canReconcile = false,
+ }
+
for _, row in ipairs(rows or {}) do
- if isIncompleteSpellColorKey(row and row.key) then
- return true
+ local keyState = getSpellColorKeyState(row and row.key)
+ if keyState then
+ state.hasRowsNeedingReconcile = state.hasRowsNeedingReconcile or keyState.isIncomplete
+ state.showSecretNameWarning = state.showSecretNameWarning or keyState.hasSecretName
+
+ if state.hasRowsNeedingReconcile and state.showSecretNameWarning then
+ break
+ end
end
end
- return false
+ if InCombatLockdown() then
+ state.warningText = L["SPELL_COLORS_COMBAT_WARNING"]
+ end
+
+ state.canReconcile = state.hasRowsNeedingReconcile and not isSpellColorsReconcileRestricted()
+
+ return state
end
---@param rows { key: ECM_SpellColorKey }[]|nil
@@ -101,7 +100,8 @@ local function collectIncompleteSpellColorRows(rows)
local incompleteRows = {}
for _, row in ipairs(rows or {}) do
- if isIncompleteSpellColorKey(row and row.key) then
+ local keyState = getSpellColorKeyState(row and row.key)
+ if keyState and keyState.isIncomplete then
incompleteRows[#incompleteRows + 1] = row
end
end
@@ -176,12 +176,20 @@ end
-- Canvas Frame for Spell Colors
--------------------------------------------------------------------------------
-local function createSpellColorCanvas(SB, subcatName)
+local function createSpellColorPage(subcatName)
+ local registeredPage
+
+ local function refreshPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+ end
+
local function resetAllSpellColors()
ns.SpellColors.ClearCurrentSpecColors()
ns.SpellColors.SetDefaultColor(C.BUFFBARS_DEFAULT_COLOR)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- SB.RefreshCategory(subcatName)
+ refreshPage()
end
local function reconcileSpellColors()
@@ -212,7 +220,7 @@ local function createSpellColorCanvas(SB, subcatName)
if #removedKeys > 0 then
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- SB.RefreshCategory(subcatName)
+ refreshPage()
end
end
)
@@ -222,17 +230,6 @@ local function createSpellColorCanvas(SB, subcatName)
return buildSpellColorRows(ns.SpellColors.GetAllColorEntries())
end
- local function getWarningText()
- local parts = {}
- local locked, reason = ns.Addon.BuffBars:IsEditLocked()
- if locked and reason == "combat" then
- parts[#parts + 1] = L["SPELL_COLORS_COMBAT_WARNING"]
- elseif locked and reason == "secrets" then
- parts[#parts + 1] = L["SPELL_COLORS_SECRETS_WARNING"]
- end
- return table.concat(parts, "\n")
- end
-
local function buildSpellColorItems()
local items = {}
local rows = getRows()
@@ -255,7 +252,7 @@ local function createSpellColorCanvas(SB, subcatName)
ns.OptionUtil.OpenColorPicker(ns.SpellColors.GetDefaultColor(), false, function(color)
ns.SpellColors.SetDefaultColor(color)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- SB.RefreshCategory(subcatName)
+ refreshPage()
end)
end,
},
@@ -278,7 +275,7 @@ local function createSpellColorCanvas(SB, subcatName)
ns.OptionUtil.OpenColorPicker(current, false, function(color)
ns.SpellColors.SetColorByKey(row.key, color)
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- SB.RefreshCategory(subcatName)
+ refreshPage()
end)
end,
},
@@ -293,8 +290,12 @@ local function createSpellColorCanvas(SB, subcatName)
return items
end
- SB.RegisterPage({
+ local pageSpec = {
+ key = "spellColors",
name = subcatName,
+ onRegistered = function(page)
+ registeredPage = page
+ end,
rows = {
{
id = "spellColorsPageActions",
@@ -305,11 +306,10 @@ local function createSpellColorCanvas(SB, subcatName)
text = L["SPELL_COLORS_RECONCILE_BUTTON"],
width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
enabled = function()
- local rows = getRows()
- return hasRowsNeedingReconcile(rows) and not isSpellColorsReloadRestricted()
+ return getSpellColorsPageState(getRows()).canReconcile
end,
onClick = function()
- if hasRowsNeedingReconcile(getRows()) and not isSpellColorsReloadRestricted() then
+ if getSpellColorsPageState(getRows()).canReconcile then
reconcileSpellColors()
end
end,
@@ -319,11 +319,10 @@ local function createSpellColorCanvas(SB, subcatName)
width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
tooltip = L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
enabled = function()
- local rows = getRows()
- return hasRowsNeedingReconcile(rows) and not isSpellColorsReloadRestricted()
+ return getSpellColorsPageState(getRows()).canReconcile
end,
onClick = function()
- if hasRowsNeedingReconcile(getRows()) and not isSpellColorsReloadRestricted() then
+ if getSpellColorsPageState(getRows()).canReconcile then
removeStaleSpellColors()
end
end,
@@ -343,12 +342,14 @@ local function createSpellColorCanvas(SB, subcatName)
id = "spellColorsWarning",
type = "info",
name = "",
- value = getWarningText,
+ value = function()
+ return getSpellColorsPageState(getRows()).warningText
+ end,
wide = true,
multiline = true,
height = 30,
hidden = function()
- return getWarningText() == ""
+ return getSpellColorsPageState(getRows()).warningText == ""
end,
},
{
@@ -369,26 +370,12 @@ local function createSpellColorCanvas(SB, subcatName)
multiline = true,
height = C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT,
hidden = function()
- return not getSecretNameFooterState(getRows()).show
- end,
- },
- {
- id = "secretNameReload",
- type = "button",
- name = " ",
- buttonText = L["SPELL_COLORS_RELOAD_BUTTON"],
- hidden = function()
- return not getSecretNameFooterState(getRows()).show
- end,
- disabled = function()
- return not getSecretNameFooterState(getRows()).enabled
- end,
- onClick = function()
- ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
+ return not getSpellColorsPageState(getRows()).showSecretNameWarning
end,
},
},
- })
+ }
+ return pageSpec
end
--------------------------------------------------------------------------------
@@ -398,23 +385,21 @@ end
local BuffBarsOptions = {}
ns.BuffBarsOptions = BuffBarsOptions
BuffBarsOptions._BuildSpellColorRows = buildSpellColorRows
-BuffBarsOptions._HasUnlabeledBars = hasUnlabeledBars
-BuffBarsOptions._HasRowsNeedingReconcile = hasRowsNeedingReconcile
BuffBarsOptions._CollectIncompleteSpellColorRows = collectIncompleteSpellColorRows
-BuffBarsOptions._IsSpellColorsReloadRestricted = isSpellColorsReloadRestricted
-BuffBarsOptions._GetSecretNameFooterState = getSecretNameFooterState
+BuffBarsOptions._GetSpellColorsPageState = getSpellColorsPageState
BuffBarsOptions._BuildSpellColorKeyTooltipLines = buildSpellColorKeyTooltipLines
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
-function BuffBarsOptions.RegisterSettings(SB)
- local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
- local layoutMovedButton = ns.OptionUtil.CreateLayoutBreadcrumbArgs(10).layoutMovedButton
- layoutMovedButton.id = "layoutMovedButton"
+local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
+local layoutMovedButton = ns.OptionUtil.CreateLayoutBreadcrumbArgs(10).layoutMovedButton
+layoutMovedButton.id = "layoutMovedButton"
- SB.RegisterPage({
- name = L["AURA_BARS"],
- path = "buffBars",
+BuffBarsOptions.key = "buffBars"
+BuffBarsOptions.name = L["AURA_BARS"]
+BuffBarsOptions.pages = {
+ {
+ key = "main",
rows = {
{
id = "enabled",
@@ -479,9 +464,6 @@ function BuffBarsOptions.RegisterSettings(SB)
},
{ id = "fontOverride", type = "fontOverride", disabled = isDisabled },
},
- })
-
- createSpellColorCanvas(SB, L["SPELL_COLORS_SUBCAT"])
-end
-
-ns.SettingsBuilder.RegisterSection(ns, "BuffBars", BuffBarsOptions)
+ },
+ createSpellColorPage(L["SPELL_COLORS_SUBCAT"]),
+}
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index dc5c1151..f9cfbb48 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -3,698 +3,49 @@
-- Licensed under the GNU General Public License v3.0
local _, ns = ...
-local C = ns.Constants
local L = ns.L
-
-local BUILTIN_STACKS = C.BUILTIN_STACKS
-local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
-local RACIAL_ABILITIES = C.RACIAL_ABILITIES
-local BUILTIN_EQUIP_SLOTS = {}
-local VIEWER_COLLECTION_HEIGHT = 448
-local ACTION_ICON_BUTTON_SIZE = 20
-local DEFAULT_SPECIAL_VIEWER = "utility"
-local VIEWER_ORDER = { "utility", "main" }
-local VIEWER_LABELS = { utility = "UTILITY_VIEWER_ICONS", main = "MAIN_VIEWER_ICONS" }
-
-local ACTION_BUTTON_TEXTURE_BASE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\"
-local function makeTexturePair(name)
- return { normal = ACTION_BUTTON_TEXTURE_BASE .. name .. "_normal", pushed = ACTION_BUTTON_TEXTURE_BASE .. name .. "_down" }
-end
-local ACTION_BUTTON_TEXTURES = {
- delete = makeTexturePair("delete"), hide = makeTexturePair("hide"),
- moveDown = makeTexturePair("move_down"), moveUp = makeTexturePair("move_up"),
- show = makeTexturePair("show"), swap = makeTexturePair("swap"),
-}
-
-local BUILTIN_STACK_SET = {}
-for _, key in ipairs(BUILTIN_STACK_ORDER) do BUILTIN_STACK_SET[key] = true end
-for _, stack in pairs(BUILTIN_STACKS) do
- if stack.kind == "equipSlot" and stack.slotId then BUILTIN_EQUIP_SLOTS[stack.slotId] = true end
-end
-
-local function isDisabledBuiltinEntry(entry)
- return entry and entry.stackKey and entry.disabled and BUILTIN_STACK_SET[entry.stackKey] == true
-end
-
-local ExtraIconsOptions = {}
-ns.ExtraIconsOptions = ExtraIconsOptions
-ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
-
---------------------------------------------------------------------------------
--- Entry Data Helpers
---------------------------------------------------------------------------------
-
-local function getEntrySpellId(entry)
- if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then return nil end
- local first = entry.ids[1]
- return type(first) == "table" and first.spellId or first
-end
-
-local function getItemIdFromEntry(entry)
- return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
-end
-
-local function getCurrentRacialEntry()
- local _, raceFile = UnitRace("player")
- local entry = RACIAL_ABILITIES[raceFile]
- if entry then return entry end
- for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry.spellId and C_SpellBook.IsSpellKnown(racialEntry.spellId) then return racialEntry end
- end
- return nil
-end
-
-local function getCurrentRacialSpellId()
- local racial = getCurrentRacialEntry()
- return racial and racial.spellId or nil
-end
-
-local function getItemDisplayName(itemId)
- if not itemId then return nil end
- local name = C_Item.GetItemNameByID(itemId)
- if name then
- ExtraIconsOptions._pendingItemLoads[itemId] = nil
- return name
- end
- if C_Item.DoesItemExistByID(itemId) then
- ExtraIconsOptions._pendingItemLoads[itemId] = true
- C_Item.RequestLoadItemDataByID(itemId)
- return L["EXTRA_ICONS_ITEM_LOADING"]
- end
- return "Item " .. tostring(itemId)
-end
-
-function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if entry.stackKey == stackKey then return true end
- end
- end
- return false
-end
-
-function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
- local stack = stackKey and BUILTIN_STACKS[stackKey]
- if not stack or stack.kind ~= "equipSlot" then return true end
- local itemId = GetInventoryItemID("player", stack.slotId)
- if not itemId then return false end
- local _, spellId = C_Item.GetItemSpell(itemId)
- return spellId ~= nil
-end
-
-function ExtraIconsOptions._isRacialPresent(viewers, spellId)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if getEntrySpellId(entry) == spellId then return true end
- end
- end
- return false
-end
-
-function ExtraIconsOptions._isCurrentRacialEntry(entry)
- local racialSpellId = getCurrentRacialSpellId()
- return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
-end
-
-function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- local spellId = getEntrySpellId(entry)
- if not spellId then return true end
- local racial = getCurrentRacialEntry()
- if not racial then return true end
- for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry ~= racial and spellId == racialEntry.spellId then return false end
- end
- return true
-end
-
---------------------------------------------------------------------------------
--- Entry Display
---------------------------------------------------------------------------------
-
-function ExtraIconsOptions._getEntryName(entry)
- if entry.stackKey then
- local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then return entry.stackKey end
- if stack.kind == "equipSlot" then
- local itemId = GetInventoryItemID("player", stack.slotId)
- local itemName = itemId and getItemDisplayName(itemId)
- if itemName then return ("%s [%s]"):format(stack.label, itemName) end
- end
- return stack.label
- end
- if entry.kind == "spell" and entry.ids then
- local spellId = getEntrySpellId(entry)
- local spellAPI = type(C_Spell) == "table" and C_Spell or nil
- local name = spellId and spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(spellId)
- return name or ("Spell " .. tostring(spellId))
- end
- if entry.kind == "item" and entry.ids then
- return getItemDisplayName(getItemIdFromEntry(entry.ids[1]))
- end
- return "Unknown"
-end
-
-function ExtraIconsOptions._getEntryIcon(entry)
- if entry.stackKey then
- local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then return nil end
- if stack.kind == "equipSlot" then return GetInventoryItemTexture("player", stack.slotId) end
- if stack.ids and stack.ids[1] then
- local itemId = getItemIdFromEntry(stack.ids[1])
- return itemId and C_Item.GetItemIconByID(itemId)
- end
- return nil
- end
- if entry.kind == "spell" then
- local spellId = getEntrySpellId(entry)
- local spellAPI = type(C_Spell) == "table" and C_Spell or nil
- return spellId and spellAPI and spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(spellId)
- end
- if entry.kind == "item" and entry.ids then
- local itemId = getItemIdFromEntry(entry.ids[1])
- return itemId and C_Item.GetItemIconByID(itemId)
- end
- return nil
-end
-
-local function getEntryTooltipTitle(entry)
- local name = ExtraIconsOptions._getEntryName(entry)
- if type(entry) ~= "table" then return name end
- if entry.kind == "spell" then
- local id = getEntrySpellId(entry)
- if id then return ("%s (spell ID %s)"):format(name, id) end
- elseif entry.kind == "item" and entry.ids and entry.ids[1] then
- local id = getItemIdFromEntry(entry.ids[1])
- if id then return ("%s (item ID %s)"):format(name, id) end
- end
- return name
-end
-
-local function showRowTooltip(owner, rowData)
- if not rowData then return end
- local displayEntry = rowData.displayEntry
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then GameTooltip:ClearLines() end
- GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1, 1, false)
- local function tip(text)
- if text and text ~= "" then GameTooltip:AddLine(text, 1, 1, 1, true) end
- end
- if rowData.isBuiltin and rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
- elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
- end
- if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
- end
- local stack = displayEntry.stackKey and BUILTIN_STACKS[displayEntry.stackKey]
- if stack and stack.kind == "item" and stack.ids and #stack.ids > 0 then
- tip(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"])
- for _, itemEntry in ipairs(stack.ids) do
- local itemId = getItemIdFromEntry(itemEntry)
- local parts = {}
- local icon = itemId and C_Item.GetItemIconByID(itemId)
- if icon and type(CreateTextureMarkup) == "function" then
- parts[#parts + 1] = CreateTextureMarkup(icon, 64, 64, 14, 14, 0, 1, 0, 1)
- end
- parts[#parts + 1] = getItemDisplayName(itemId) or ("Item " .. tostring(itemId))
- local quality = type(itemEntry) == "table" and itemEntry.quality
- if quality and type(CreateAtlasMarkup) == "function" then
- parts[#parts + 1] = CreateAtlasMarkup("Professions-Icon-Quality-Tier" .. quality .. "-Small", 14, 14)
- elseif quality then
- parts[#parts + 1] = "[R" .. quality .. "]"
- end
- tip(table.concat(parts, " "))
- end
- end
- GameTooltip:Show()
-end
-
---------------------------------------------------------------------------------
--- Entry Mutations
---------------------------------------------------------------------------------
-
-local function appendToViewer(viewers, viewerKey, entry)
- viewers[viewerKey] = viewers[viewerKey] or {}
- viewers[viewerKey][#viewers[viewerKey] + 1] = entry
-end
-
-function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
- local viewers = profile.extraIcons.viewers
- if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- appendToViewer(viewers, viewerKey, { stackKey = stackKey })
- end
-end
-
-function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
- local viewers = profile.extraIcons.viewers
- if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then
- appendToViewer(viewers, viewerKey, { kind = "spell", ids = { spellId } })
- end
-end
-
-function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
- local viewers = profile.extraIcons.viewers
- viewers[viewerKey] = viewers[viewerKey] or {}
- local entry = { kind = kind, ids = {} }
- for _, id in ipairs(ids) do
- entry.ids[#entry.ids + 1] = kind == "item" and { itemID = id } or id
- end
- if not ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
- viewers[viewerKey][#viewers[viewerKey] + 1] = entry
- end
-end
-
-function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
- local entries = profile.extraIcons.viewers[viewerKey]
- if entries and index >= 1 and index <= #entries then table.remove(entries, index) end
-end
-
-function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
- local entries = profile.extraIcons.viewers[viewerKey]
- local entry = entries and entries[index]
- if entry then entry.disabled = disabled and true or nil end
-end
-
-function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
- if index then
- local entry = (profile.extraIcons.viewers[viewerKey] or {})[index]
- if entry then ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled) end
- else
- ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
- end
-end
-
-function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
- if index then
- ExtraIconsOptions._removeEntry(profile, viewerKey, index)
- elseif spellId then
- ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
- end
-end
-
-local function isVisibleActiveViewerEntry(entry)
- return not isDisabledBuiltinEntry(entry)
- and ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey))
-end
-
-function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
- local entries = profile.extraIcons.viewers[viewerKey]
- if not entries then return end
- local visibleIndices, activeIndex = {}, nil
- for i, entry in ipairs(entries) do
- if isVisibleActiveViewerEntry(entry) then
- visibleIndices[#visibleIndices + 1] = i
- if i == index then activeIndex = #visibleIndices end
- end
- end
- if not activeIndex then return end
- local target = visibleIndices[activeIndex + direction]
- if target then entries[index], entries[target] = entries[target], entries[index] end
-end
-
-function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
- local from = profile.extraIcons.viewers[fromViewer]
- if not from or index < 1 or index > #from then return end
- if ExtraIconsOptions._findDuplicateEntry(profile.extraIcons.viewers, from[index], fromViewer, index) == toViewer then return end
- local entry = table.remove(from, index)
- local to = profile.extraIcons.viewers[toViewer] or {}
- profile.extraIcons.viewers[toViewer] = to
- to[#to + 1] = entry
-end
-
-function ExtraIconsOptions._otherViewer(viewerKey)
- return viewerKey == "utility" and "main" or "utility"
-end
-
---------------------------------------------------------------------------------
--- Parsing and Resolution
---------------------------------------------------------------------------------
-
-function ExtraIconsOptions._parseSingleId(text)
- if not text or text == "" then return nil end
- local num = tonumber(text)
- if not num or num <= 0 or num ~= math.floor(num) then return nil end
- return num
-end
-
-function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
- local id = ExtraIconsOptions._parseSingleId(text)
- if not id then return "invalid", nil, nil end
- if kind == "spell" then
- local spellAPI = type(C_Spell) == "table" and C_Spell or nil
- local name = spellAPI and spellAPI.GetSpellName and spellAPI.GetSpellName(id)
- if not name then return "invalid", nil, nil end
- return "resolved", name, spellAPI.GetSpellTexture and spellAPI.GetSpellTexture(id)
- end
- if kind == "item" then
- if not C_Item.DoesItemExistByID(id) then return "invalid", nil, nil end
- local name = C_Item.GetItemNameByID(id)
- local icon = C_Item.GetItemIconByID(id)
- if name then
- ExtraIconsOptions._pendingItemLoads[id] = nil
- return "resolved", name, icon
- end
- ExtraIconsOptions._pendingItemLoads[id] = true
- C_Item.RequestLoadItemDataByID(id)
- return "pending", nil, icon
- end
- return "invalid", nil, nil
-end
-
---------------------------------------------------------------------------------
--- Duplicate Detection
---------------------------------------------------------------------------------
-
-local function getEntryIdentityKey(entry)
- if not entry then return nil end
- if entry.stackKey then return "stack:" .. entry.stackKey end
- if not (entry.kind and entry.ids and #entry.ids > 0) then return nil end
- local parts = { entry.kind }
- for _, id in ipairs(entry.ids) do
- if entry.kind == "spell" then
- parts[#parts + 1] = tostring(type(id) == "table" and id.spellId or id)
- else
- parts[#parts + 1] = tostring(getItemIdFromEntry(id))
- end
- end
- return table.concat(parts, ":")
-end
-
-function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- local candidateKey = getEntryIdentityKey(candidateEntry)
- if not candidateKey then return nil, nil end
- for viewerKey, entries in pairs(viewers) do
- for index, entry in ipairs(entries) do
- if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
- and getEntryIdentityKey(entry) == candidateKey then
- return viewerKey, index
- end
- end
- end
- return nil, nil
-end
-
-function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- return ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) ~= nil
-end
-
---------------------------------------------------------------------------------
--- Row Building
---------------------------------------------------------------------------------
-
-function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
- local activeRows, disabledBuiltinRows = {}, {}
- for index, entry in ipairs(viewers[viewerKey] or {}) do
- if ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
- local rowData = {
- rowType = "entry", viewerKey = viewerKey, index = index,
- entry = entry, displayEntry = entry,
- isBuiltin = entry.stackKey ~= nil,
- isCurrentRacial = ExtraIconsOptions._isCurrentRacialEntry(entry),
- isPlaceholder = false, isDisabled = entry.disabled == true,
- }
- if isDisabledBuiltinEntry(entry) then
- local bucket = disabledBuiltinRows[entry.stackKey] or {}
- disabledBuiltinRows[entry.stackKey] = bucket
- bucket[#bucket + 1] = rowData
- else
- activeRows[#activeRows + 1] = rowData
- end
- end
- end
- for i, rowData in ipairs(activeRows) do
- rowData.activeIndex = i
- rowData.activeCount = #activeRows
- end
- local rows = activeRows
- for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- local bucket = disabledBuiltinRows[stackKey]
- if bucket then
- for _, rowData in ipairs(bucket) do rows[#rows + 1] = rowData end
- elseif viewerKey == DEFAULT_SPECIAL_VIEWER
- and ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
- and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- rows[#rows + 1] = {
- rowType = "builtinPlaceholder", viewerKey = viewerKey, stackKey = stackKey,
- displayEntry = { stackKey = stackKey },
- isBuiltin = true, isCurrentRacial = false, isPlaceholder = true, isDisabled = true,
- }
- end
- end
- if viewerKey == DEFAULT_SPECIAL_VIEWER then
- local racialSpellId = getCurrentRacialSpellId()
- if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
- rows[#rows + 1] = {
- rowType = "racialPlaceholder", viewerKey = viewerKey, spellId = racialSpellId,
- displayEntry = { kind = "spell", ids = { racialSpellId } },
- isBuiltin = false, isCurrentRacial = true, isPlaceholder = true, isDisabled = true,
- }
- end
- end
- return rows
-end
-
---------------------------------------------------------------------------------
--- Settings Registration
---------------------------------------------------------------------------------
-
StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
ns.OptionUtil.MakeConfirmDialog(L["REMOVE_ENTRY_CONFIRM"])
-function ExtraIconsOptions.RegisterSettings(SB)
- local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
- local categoryName = L["EXTRA_ICONS"]
- local category
-
- local function getProfile() return ns.Addon.db.profile end
- local function getViewers() return getProfile().extraIcons.viewers end
- local function refreshCategory()
- if category then SB.RefreshCategory(category) else SB.RefreshCategory(categoryName) end
- end
- local function doAction(fn)
- if fn then fn() end
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshCategory()
- end
- local function getViewerShortLabel(viewerKey)
- return viewerKey == "utility" and L["UTILITY_VIEWER_SHORT"] or L["MAIN_VIEWER_SHORT"]
- end
-
- ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
- local draftStates = ExtraIconsOptions._draftStates
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
- end
-
- local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
- if not itemLoadFrame then
- itemLoadFrame = CreateFrame("Frame")
- ExtraIconsOptions._itemLoadFrame = itemLoadFrame
- end
- if not itemLoadFrame._ecmHooked then
- itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
- itemLoadFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
- itemLoadFrame:SetScript("OnEvent", function(_, event, arg1)
- if event == "GET_ITEM_INFO_RECEIVED" and arg1 and ExtraIconsOptions._pendingItemLoads[arg1] then
- ExtraIconsOptions._pendingItemLoads[arg1] = nil
- refreshCategory()
- elseif event == "PLAYER_EQUIPMENT_CHANGED" and BUILTIN_EQUIP_SLOTS[arg1] then
- refreshCategory()
- end
- end)
- itemLoadFrame._ecmHooked = true
- end
-
- local function buildDraftEntry(kind, id)
- if not id then return nil end
- if kind == "item" then return { kind = "item", ids = { { itemID = id } } } end
- if kind == "spell" then return { kind = "spell", ids = { id } } end
- return nil
- end
-
- local function getDraftDuplicateInfo(viewerKey)
- local ds = draftStates[viewerKey]
- local entry = buildDraftEntry(ds.kind, ExtraIconsOptions._parseSingleId(ds.idText))
- local dupViewer = entry and ExtraIconsOptions._findDuplicateEntry(getViewers(), entry) or nil
- return dupViewer ~= nil, dupViewer
- end
-
- local function addDraftEntry(viewerKey)
- local ds = draftStates[viewerKey]
- local status = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
- if status ~= "resolved" or getDraftDuplicateInfo(viewerKey) then return false end
- local id = ExtraIconsOptions._parseSingleId(ds.idText)
- ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, ds.kind, { id })
- ds.idText = ""
- doAction()
- return true
- end
-
- local function makeAction(text, textures, enabled, tooltip, onClick)
- return {
- text = text, width = ACTION_ICON_BUTTON_SIZE, height = ACTION_ICON_BUTTON_SIZE,
- buttonTextures = textures, enabled = enabled, tooltip = tooltip, onClick = onClick,
- }
- end
-
- local function buildActionItem(rowData)
- local controlsDisabled = isDisabled()
- local displayEntry = rowData.displayEntry
- local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
- local dupViewer = rowData.index ~= nil
- and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index) or nil
- local hasMoveDup = dupViewer == otherViewer
- local posLocked = rowData.isBuiltin and rowData.isDisabled
- local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
- local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
-
- local delText, delTex, delTip = "x", ACTION_BUTTON_TEXTURES.delete, L["REMOVE_TOOLTIP"]
- local delAction = function()
- StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
- onAccept = function() doAction(function()
- ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
- end) end,
- })
- end
- if rowData.isBuiltin then
- delText = rowData.isDisabled and "+" or "x"
- delTex = rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide
- delTip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
- delAction = function() doAction(function()
- ExtraIconsOptions._toggleBuiltinRow(getProfile(), rowData.viewerKey, rowData.index, rowData.stackKey or displayEntry.stackKey)
- end) end
- elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- delText, delTex, delTip = "+", ACTION_BUTTON_TEXTURES.show, L["ADD_ENTRY"]
- delAction = function() doAction(function()
- ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
- end) end
- end
-
- return {
- label = ExtraIconsOptions._getEntryName(displayEntry),
- icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
- alpha = rowData.isDisabled and 0.55 or 1,
- labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
- labelColor = rowData.isDisabled and { 0.65, 0.65, 0.65, 1 } or { 1, 0.82, 0, 1 },
- iconDesaturated = rowData.isDisabled == true,
- iconVertexColor = rowData.isDisabled and { 0.6, 0.6, 0.6, 1 } or nil,
- onEnter = function(owner) showRowTooltip(owner, rowData) end,
- onLeave = function() GameTooltip_Hide() end,
- actions = {
- up = makeAction("^", ACTION_BUTTON_TEXTURES.moveUp, canReorder and rowData.activeIndex > 1, L["MOVE_UP_TOOLTIP"],
- function() doAction(function() ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1) end) end),
- down = makeAction("v", ACTION_BUTTON_TEXTURES.moveDown, canReorder and rowData.activeIndex < rowData.activeCount, L["MOVE_DOWN_TOOLTIP"],
- function() doAction(function() ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1) end) end),
- move = makeAction(rowData.viewerKey == "utility" and ">" or "<", ACTION_BUTTON_TEXTURES.swap, canMove,
- function()
- if hasMoveDup then return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer)) end
- if posLocked then return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"] end
- return L["MOVE_TO_VIEWER_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
- end,
- function() doAction(function() ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index) end) end),
- delete = makeAction(delText, delTex, not controlsDisabled, delTip, delAction),
- },
- }
- end
-
- local function buildModeInputTrailer(viewerKey)
- local ds = draftStates[viewerKey]
- local function getPreviewState()
- local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
- local isDup, dupViewer = getDraftDuplicateInfo(viewerKey)
- return status, name, icon, isDup, dupViewer
- end
- local function toggleKind()
- if isDisabled() then return false end
- ds.kind = ds.kind == "spell" and "item" or "spell"
- return true
- end
- return {
- type = "modeInput",
- disabled = isDisabled,
- modeText = function() return ds.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"] end,
- modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
- inputText = function() return ds.idText end,
- placeholder = function()
- return ds.kind == "spell" and L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] or L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
- end,
- previewIcon = function() local _, _, icon = getPreviewState(); return icon end,
- previewText = function()
- local status, name, _, isDup, dupViewer = getPreviewState()
- if status == "resolved" and isDup then return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(dupViewer)) end
- if status == "resolved" then return name or "" end
- if status == "pending" then return "..." end
- return nil
- end,
- submitText = L["ADD_ENTRY"],
- submitTooltip = L["ADD_ENTRY"],
- submitEnabled = function()
- local s, _, _, d = getPreviewState()
- return s == "resolved" and not d
- end,
- onToggleMode = toggleKind,
- onTextChanged = function(text) ds.idText = text or "" end,
- onSubmit = function()
- if isDisabled() then return false end
- return addDraftEntry(viewerKey)
- end,
- onTabPressed = toggleKind,
- }
- end
-
- ExtraIconsOptions._refresh = refreshCategory
-
- SB.RegisterPage({
- name = categoryName,
- path = "extraIcons",
- onShow = function() ns.Runtime.SetLayoutPreview(true) end,
- onHide = function() ns.Runtime.SetLayoutPreview(false) end,
- rows = {
- {
- id = "enabled", type = "checkbox", path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"], desc = L["ENABLE_EXTRA_ICONS_DESC"],
- onSet = function(value) ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(value) end,
- },
- {
- id = "specialRowsLegend", type = "info", name = "",
- value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
- wide = true, multiline = true, height = 24,
- },
- {
- id = "viewers", type = "sectionList", height = VIEWER_COLLECTION_HEIGHT,
- disabled = isDisabled,
- sections = function()
- local viewers = getViewers()
- local sections = {}
- for _, vk in ipairs(VIEWER_ORDER) do
- local items = {}
- for _, rowData in ipairs(ExtraIconsOptions._buildViewerRows(viewers, vk)) do
- items[#items + 1] = buildActionItem(rowData)
- end
- sections[#sections + 1] = {
- key = vk, title = L[VIEWER_LABELS[vk]], items = items,
- emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
- footer = buildModeInputTrailer(vk),
- }
- end
- return sections
- end,
- onDefault = function()
- local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
- if not (defaults and defaults.extraIcons) then return end
- ns.Addon.db.profile.extraIcons = ns.CloneValue(defaults.extraIcons)
- for _, vk in ipairs(VIEWER_ORDER) do draftStates[vk].kind = "spell"; draftStates[vk].idText = "" end
- doAction()
- end,
- },
- },
- })
-
- category = SB.GetSubcategory(categoryName)
- ExtraIconsOptions._category = category
-end
+local ExtraIconsOptions = ns.ExtraIconsOptions or {}
+local Util = assert(ns.ExtraIconsOptionsUtil, "ExtraIconsOptionsUtil missing")
+
+ExtraIconsOptions.key = "extraIcons"
+ExtraIconsOptions.name = L["EXTRA_ICONS"]
+
+function ExtraIconsOptions.onShow()
+ ns.Runtime.SetLayoutPreview(true)
+end
+
+function ExtraIconsOptions.onHide()
+ ns.Runtime.SetLayoutPreview(false)
+end
+
+function ExtraIconsOptions.onRegistered(page)
+ Util.SetRegisteredPage(page)
+ Util.EnsureItemLoadFrame()
+end
+
+ExtraIconsOptions.rows = {
+ {
+ id = "enabled", type = "checkbox", path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"], desc = L["ENABLE_EXTRA_ICONS_DESC"],
+ onSet = function(value, _, page)
+ ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(value)
+ page:Refresh()
+ end,
+ },
+ {
+ id = "specialRowsLegend", type = "info", name = "",
+ value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
+ wide = true, multiline = true, height = 24,
+ },
+ {
+ id = "viewers", type = "sectionList", height = Util.VIEWER_COLLECTION_HEIGHT,
+ disabled = Util.IsDisabled,
+ sections = Util.BuildSections,
+ onDefault = Util.ResetToDefaults,
+ },
+}
-ns.SettingsBuilder.RegisterSection(ns, "ExtraIcons", ExtraIconsOptions)
+ns.ExtraIconsOptions = ExtraIconsOptions
diff --git a/UI/ExtraIconsOptionsUtil.lua b/UI/ExtraIconsOptionsUtil.lua
new file mode 100644
index 00000000..a034a6af
--- /dev/null
+++ b/UI/ExtraIconsOptionsUtil.lua
@@ -0,0 +1,901 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local C = ns.Constants
+local L = ns.L
+
+local BUILTIN_STACKS = C.BUILTIN_STACKS
+local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
+local RACIAL_ABILITIES = C.RACIAL_ABILITIES
+
+local VIEWER_COLLECTION_HEIGHT = 448
+local ACTION_ICON_BUTTON_SIZE = 20
+local DEFAULT_SPECIAL_VIEWER = "utility"
+local VIEWER_ORDER = { "utility", "main" }
+local VIEWER_LABELS = {
+ utility = L["UTILITY_VIEWER_ICONS"],
+ main = L["MAIN_VIEWER_ICONS"],
+}
+local VIEWER_SHORT_LABELS = {
+ utility = L["UTILITY_VIEWER_SHORT"],
+ main = L["MAIN_VIEWER_SHORT"],
+}
+
+local ACTION_BUTTON_TEXTURE_BASE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\"
+local SPELL_API = type(C_Spell) == "table" and C_Spell or nil
+
+local function makeTexturePair(name)
+ return { normal = ACTION_BUTTON_TEXTURE_BASE .. name .. "_normal", pushed = ACTION_BUTTON_TEXTURE_BASE .. name .. "_down" }
+end
+
+local ACTION_BUTTON_TEXTURES = {
+ delete = makeTexturePair("delete"),
+ hide = makeTexturePair("hide"),
+ moveDown = makeTexturePair("move_down"),
+ moveUp = makeTexturePair("move_up"),
+ show = makeTexturePair("show"),
+ swap = makeTexturePair("swap"),
+}
+
+local BUILTIN_STACK_SET = {}
+local BUILTIN_EQUIP_SLOTS = {}
+for _, key in ipairs(BUILTIN_STACK_ORDER) do
+ BUILTIN_STACK_SET[key] = true
+end
+for _, stack in pairs(BUILTIN_STACKS) do
+ if stack.kind == "equipSlot" and stack.slotId then
+ BUILTIN_EQUIP_SLOTS[stack.slotId] = true
+ end
+end
+
+local ExtraIconsOptions = ns.ExtraIconsOptions or {}
+local Util = ns.ExtraIconsOptionsUtil or {}
+ns.ExtraIconsOptions = ExtraIconsOptions
+ns.ExtraIconsOptionsUtil = Util
+
+ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
+ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
+local draftStates = ExtraIconsOptions._draftStates
+
+local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
+local registeredPage
+
+for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
+end
+
+Util.IsDisabled = isDisabled
+Util.VIEWER_COLLECTION_HEIGHT = VIEWER_COLLECTION_HEIGHT
+
+local function getProfile()
+ return ns.Addon.db.profile
+end
+
+local function getViewers()
+ return getProfile().extraIcons.viewers
+end
+
+local function refreshPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+end
+
+local function doAction(fn)
+ if fn then
+ fn()
+ end
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ refreshPage()
+end
+
+local function getViewerShortLabel(viewerKey)
+ return VIEWER_SHORT_LABELS[viewerKey]
+end
+
+local function getSpellName(spellId)
+ return spellId and SPELL_API and SPELL_API.GetSpellName and SPELL_API.GetSpellName(spellId) or nil
+end
+
+local function getSpellTexture(spellId)
+ return spellId and SPELL_API and SPELL_API.GetSpellTexture and SPELL_API.GetSpellTexture(spellId) or nil
+end
+
+local function isDisabledBuiltinEntry(entry)
+ return entry and entry.stackKey and entry.disabled and BUILTIN_STACK_SET[entry.stackKey] == true
+end
+
+local function getEntrySpellId(entry)
+ if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
+ return nil
+ end
+ local first = entry.ids[1]
+ return type(first) == "table" and first.spellId or first
+end
+
+local function getItemIdFromEntry(entry)
+ return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
+end
+
+local function buildEntry(kind, ids)
+ local entryIds = {}
+ for _, id in ipairs(ids) do
+ entryIds[#entryIds + 1] = kind == "item" and { itemID = getItemIdFromEntry(id) } or id
+ end
+ return { kind = kind, ids = entryIds }
+end
+
+local function getViewerEntries(viewers, viewerKey)
+ local entries = viewers[viewerKey]
+ if entries then
+ return entries
+ end
+ entries = {}
+ viewers[viewerKey] = entries
+ return entries
+end
+
+local function getCurrentRacialEntry()
+ local _, raceFile = UnitRace("player")
+ local entry = RACIAL_ABILITIES[raceFile]
+ if entry then
+ return entry
+ end
+ for _, racialEntry in pairs(RACIAL_ABILITIES) do
+ if racialEntry.spellId and C_SpellBook.IsSpellKnown(racialEntry.spellId) then
+ return racialEntry
+ end
+ end
+ return nil
+end
+
+local function getCurrentRacialSpellId()
+ local racial = getCurrentRacialEntry()
+ return racial and racial.spellId or nil
+end
+
+local function getItemDisplayName(itemId)
+ if not itemId then
+ return nil
+ end
+
+ local name = C_Item.GetItemNameByID(itemId)
+ if name then
+ ExtraIconsOptions._pendingItemLoads[itemId] = nil
+ return name
+ end
+
+ if C_Item.DoesItemExistByID(itemId) then
+ ExtraIconsOptions._pendingItemLoads[itemId] = true
+ C_Item.RequestLoadItemDataByID(itemId)
+ return L["EXTRA_ICONS_ITEM_LOADING"]
+ end
+
+ return "Item " .. tostring(itemId)
+end
+
+local function getEntryTooltipTitle(entry)
+ local name = ExtraIconsOptions._getEntryName(entry)
+ if type(entry) ~= "table" then
+ return name
+ end
+ if entry.kind == "spell" then
+ local id = getEntrySpellId(entry)
+ if id then
+ return ("%s (spell ID %s)"):format(name, id)
+ end
+ elseif entry.kind == "item" and entry.ids and entry.ids[1] then
+ local id = getItemIdFromEntry(entry.ids[1])
+ if id then
+ return ("%s (item ID %s)"):format(name, id)
+ end
+ end
+ return name
+end
+
+local function getEntryIdentityKey(entry)
+ if not entry then
+ return nil
+ end
+ if entry.stackKey then
+ return "stack:" .. entry.stackKey
+ end
+ if not (entry.kind and entry.ids and #entry.ids > 0) then
+ return nil
+ end
+
+ local parts = { entry.kind }
+ for _, id in ipairs(entry.ids) do
+ if entry.kind == "spell" then
+ parts[#parts + 1] = tostring(type(id) == "table" and id.spellId or id)
+ else
+ parts[#parts + 1] = tostring(getItemIdFromEntry(id))
+ end
+ end
+ return table.concat(parts, ":")
+end
+
+local function showRowTooltip(owner, rowData)
+ if not rowData then
+ return
+ end
+
+ local displayEntry = rowData.displayEntry
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1, 1, false)
+
+ local function tip(text)
+ if text and text ~= "" then
+ GameTooltip:AddLine(text, 1, 1, 1, true)
+ end
+ end
+
+ if rowData.isBuiltin and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
+ end
+ if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ end
+
+ local stack = displayEntry.stackKey and BUILTIN_STACKS[displayEntry.stackKey]
+ if stack and stack.kind == "item" and stack.ids and #stack.ids > 0 then
+ tip(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"])
+ for _, itemEntry in ipairs(stack.ids) do
+ local itemId = getItemIdFromEntry(itemEntry)
+ local parts = {}
+ local icon = itemId and C_Item.GetItemIconByID(itemId)
+ if icon then
+ parts[#parts + 1] = CreateTextureMarkup(icon, 64, 64, 14, 14, 0, 1, 0, 1)
+ end
+ parts[#parts + 1] = getItemDisplayName(itemId) or ("Item " .. tostring(itemId))
+ local quality = type(itemEntry) == "table" and itemEntry.quality
+ if quality then
+ parts[#parts + 1] = CreateAtlasMarkup("Professions-Icon-Quality-Tier" .. quality .. "-Small", 14, 14)
+ end
+ tip(table.concat(parts, " "))
+ end
+ end
+
+ GameTooltip:Show()
+end
+
+function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if entry.stackKey == stackKey then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ local stack = stackKey and BUILTIN_STACKS[stackKey]
+ if not stack or stack.kind ~= "equipSlot" then
+ return true
+ end
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ if not itemId then
+ return false
+ end
+ local _, spellId = C_Item.GetItemSpell(itemId)
+ return spellId ~= nil
+end
+
+function ExtraIconsOptions._isRacialPresent(viewers, spellId)
+ for _, entries in pairs(viewers) do
+ for _, entry in ipairs(entries) do
+ if getEntrySpellId(entry) == spellId then
+ return true
+ end
+ end
+ end
+ return false
+end
+
+function ExtraIconsOptions._isCurrentRacialEntry(entry)
+ local racialSpellId = getCurrentRacialSpellId()
+ return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
+end
+
+function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ local spellId = getEntrySpellId(entry)
+ if not spellId then
+ return true
+ end
+
+ local racial = getCurrentRacialEntry()
+ if not racial then
+ return true
+ end
+
+ for _, racialEntry in pairs(RACIAL_ABILITIES) do
+ if racialEntry ~= racial and spellId == racialEntry.spellId then
+ return false
+ end
+ end
+ return true
+end
+
+function ExtraIconsOptions._getEntryName(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then
+ return entry.stackKey
+ end
+ if stack.kind == "equipSlot" then
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ local itemName = itemId and getItemDisplayName(itemId)
+ if itemName then
+ return ("%s [%s]"):format(stack.label, itemName)
+ end
+ end
+ return stack.label
+ end
+
+ if entry.kind == "spell" and entry.ids then
+ local spellId = getEntrySpellId(entry)
+ return getSpellName(spellId) or ("Spell " .. tostring(spellId))
+ end
+
+ if entry.kind == "item" and entry.ids then
+ return getItemDisplayName(getItemIdFromEntry(entry.ids[1]))
+ end
+
+ return "Unknown"
+end
+
+function ExtraIconsOptions._getEntryIcon(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then
+ return nil
+ end
+ if stack.kind == "equipSlot" then
+ return GetInventoryItemTexture("player", stack.slotId)
+ end
+ if stack.ids and stack.ids[1] then
+ local itemId = getItemIdFromEntry(stack.ids[1])
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+ return nil
+ end
+
+ if entry.kind == "spell" then
+ return getSpellTexture(getEntrySpellId(entry))
+ end
+
+ if entry.kind == "item" and entry.ids then
+ local itemId = getItemIdFromEntry(entry.ids[1])
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+
+ return nil
+end
+
+function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+ local viewers = profile.extraIcons.viewers
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ local entries = getViewerEntries(viewers, viewerKey)
+ entries[#entries + 1] = { stackKey = stackKey }
+ end
+end
+
+function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ local viewers = profile.extraIcons.viewers
+ if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then
+ local entries = getViewerEntries(viewers, viewerKey)
+ entries[#entries + 1] = buildEntry("spell", { spellId })
+ end
+end
+
+function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
+ local viewers = profile.extraIcons.viewers
+ local entry = buildEntry(kind, ids)
+ if not ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
+ local entries = getViewerEntries(viewers, viewerKey)
+ entries[#entries + 1] = entry
+ end
+end
+
+function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if entries and index >= 1 and index <= #entries then
+ table.remove(entries, index)
+ end
+end
+
+function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ local entry = entries and entries[index]
+ if entry then
+ entry.disabled = disabled and true or nil
+ end
+end
+
+function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
+ if not index then
+ ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+ return
+ end
+
+ local entry = (profile.extraIcons.viewers[viewerKey] or {})[index]
+ if entry then
+ ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled)
+ end
+end
+
+function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
+ if index then
+ ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ return
+ end
+ if spellId then
+ ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ end
+end
+
+function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if not entries then
+ return
+ end
+
+ local visibleIndices, activeIndex = {}, nil
+ for i, entry in ipairs(entries) do
+ if not isDisabledBuiltinEntry(entry)
+ and ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
+ visibleIndices[#visibleIndices + 1] = i
+ if i == index then
+ activeIndex = #visibleIndices
+ end
+ end
+ end
+
+ if not activeIndex then
+ return
+ end
+
+ local target = visibleIndices[activeIndex + direction]
+ if target then
+ entries[index], entries[target] = entries[target], entries[index]
+ end
+end
+
+function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
+ local viewers = profile.extraIcons.viewers
+ local from = viewers[fromViewer]
+ if not from or index < 1 or index > #from then
+ return
+ end
+ if ExtraIconsOptions._findDuplicateEntry(viewers, from[index], fromViewer, index) == toViewer then
+ return
+ end
+
+ local entry = table.remove(from, index)
+ local to = getViewerEntries(viewers, toViewer)
+ to[#to + 1] = entry
+end
+
+function ExtraIconsOptions._otherViewer(viewerKey)
+ return viewerKey == "utility" and "main" or "utility"
+end
+
+function ExtraIconsOptions._parseSingleId(text)
+ if not text or text == "" then
+ return nil
+ end
+ local num = tonumber(text)
+ if not num or num <= 0 or num ~= math.floor(num) then
+ return nil
+ end
+ return num
+end
+
+function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
+ local id = ExtraIconsOptions._parseSingleId(text)
+ if not id then
+ return "invalid", nil, nil
+ end
+
+ if kind == "spell" then
+ local name = getSpellName(id)
+ if not name then
+ return "invalid", nil, nil
+ end
+ return "resolved", name, getSpellTexture(id)
+ end
+
+ if kind == "item" then
+ if not C_Item.DoesItemExistByID(id) then
+ return "invalid", nil, nil
+ end
+ local name = C_Item.GetItemNameByID(id)
+ local icon = C_Item.GetItemIconByID(id)
+ if name then
+ ExtraIconsOptions._pendingItemLoads[id] = nil
+ return "resolved", name, icon
+ end
+ ExtraIconsOptions._pendingItemLoads[id] = true
+ C_Item.RequestLoadItemDataByID(id)
+ return "pending", nil, icon
+ end
+
+ return "invalid", nil, nil
+end
+
+function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ local candidateKey = getEntryIdentityKey(candidateEntry)
+ if not candidateKey then
+ return nil, nil
+ end
+
+ for viewerKey, entries in pairs(viewers) do
+ for index, entry in ipairs(entries) do
+ if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
+ and getEntryIdentityKey(entry) == candidateKey then
+ return viewerKey, index
+ end
+ end
+ end
+ return nil, nil
+end
+
+function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ return ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) ~= nil
+end
+
+function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
+ local activeRows, disabledBuiltinRows = {}, {}
+ for index, entry in ipairs(viewers[viewerKey] or {}) do
+ if ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
+ local rowData = {
+ rowType = "entry",
+ viewerKey = viewerKey,
+ index = index,
+ displayEntry = entry,
+ isBuiltin = entry.stackKey ~= nil,
+ isCurrentRacial = ExtraIconsOptions._isCurrentRacialEntry(entry),
+ isPlaceholder = false,
+ isDisabled = entry.disabled == true,
+ }
+ if isDisabledBuiltinEntry(entry) then
+ local bucket = disabledBuiltinRows[entry.stackKey] or {}
+ disabledBuiltinRows[entry.stackKey] = bucket
+ bucket[#bucket + 1] = rowData
+ else
+ activeRows[#activeRows + 1] = rowData
+ end
+ end
+ end
+
+ for i, rowData in ipairs(activeRows) do
+ rowData.activeIndex = i
+ rowData.activeCount = #activeRows
+ end
+
+ local rows = activeRows
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ local bucket = disabledBuiltinRows[stackKey]
+ if bucket then
+ for _, rowData in ipairs(bucket) do
+ rows[#rows + 1] = rowData
+ end
+ elseif viewerKey == DEFAULT_SPECIAL_VIEWER
+ and ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ rows[#rows + 1] = {
+ rowType = "builtinPlaceholder",
+ viewerKey = viewerKey,
+ stackKey = stackKey,
+ displayEntry = { stackKey = stackKey },
+ isBuiltin = true,
+ isCurrentRacial = false,
+ isPlaceholder = true,
+ isDisabled = true,
+ }
+ end
+ end
+
+ if viewerKey == DEFAULT_SPECIAL_VIEWER then
+ local racialSpellId = getCurrentRacialSpellId()
+ if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
+ rows[#rows + 1] = {
+ rowType = "racialPlaceholder",
+ viewerKey = viewerKey,
+ spellId = racialSpellId,
+ displayEntry = buildEntry("spell", { racialSpellId }),
+ isBuiltin = false,
+ isCurrentRacial = true,
+ isPlaceholder = true,
+ isDisabled = true,
+ }
+ end
+ end
+
+ return rows
+end
+
+function Util.SetRegisteredPage(page)
+ registeredPage = page
+end
+
+function Util.EnsureItemLoadFrame()
+ local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
+ if not itemLoadFrame then
+ itemLoadFrame = CreateFrame("Frame")
+ ExtraIconsOptions._itemLoadFrame = itemLoadFrame
+ end
+ if itemLoadFrame._ecmHooked then
+ return
+ end
+
+ itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
+ itemLoadFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
+ itemLoadFrame:SetScript("OnEvent", function(_, event, arg1)
+ if event == "GET_ITEM_INFO_RECEIVED" and arg1 and ExtraIconsOptions._pendingItemLoads[arg1] then
+ ExtraIconsOptions._pendingItemLoads[arg1] = nil
+ refreshPage()
+ elseif event == "PLAYER_EQUIPMENT_CHANGED" and BUILTIN_EQUIP_SLOTS[arg1] then
+ refreshPage()
+ end
+ end)
+ itemLoadFrame._ecmHooked = true
+end
+
+local function getDraftDuplicateInfo(viewerKey)
+ local ds = draftStates[viewerKey]
+ local id = ExtraIconsOptions._parseSingleId(ds.idText)
+ if not id then
+ return false, nil
+ end
+ local dupViewer = ExtraIconsOptions._findDuplicateEntry(getViewers(), buildEntry(ds.kind, { id }))
+ return dupViewer ~= nil, dupViewer
+end
+
+local function addDraftEntry(viewerKey)
+ local ds = draftStates[viewerKey]
+ local status = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ local isDuplicate = getDraftDuplicateInfo(viewerKey)
+ if status ~= "resolved" or isDuplicate then
+ return false
+ end
+
+ local id = ExtraIconsOptions._parseSingleId(ds.idText)
+ ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, ds.kind, { id })
+ ds.idText = ""
+ doAction()
+ return true
+end
+
+local function makeAction(text, textures, enabled, tooltip, onClick)
+ return {
+ text = text,
+ width = ACTION_ICON_BUTTON_SIZE,
+ height = ACTION_ICON_BUTTON_SIZE,
+ buttonTextures = textures,
+ enabled = enabled,
+ tooltip = tooltip,
+ onClick = onClick,
+ }
+end
+
+local function buildActionItem(rowData)
+ local controlsDisabled = isDisabled()
+ local displayEntry = rowData.displayEntry
+ local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
+ local dupViewer = rowData.index ~= nil
+ and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index) or nil
+ local hasMoveDup = dupViewer == otherViewer
+ local posLocked = rowData.isBuiltin and rowData.isDisabled
+ local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
+ local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
+
+ local delText = "x"
+ local delTex = ACTION_BUTTON_TEXTURES.delete
+ local delTip = L["REMOVE_TOOLTIP"]
+ local delAction = function()
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
+ onAccept = function()
+ doAction(function()
+ ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
+ end)
+ end,
+ })
+ end
+
+ if rowData.isBuiltin then
+ delText = rowData.isDisabled and "+" or "x"
+ delTex = rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide
+ delTip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
+ delAction = function()
+ doAction(function()
+ ExtraIconsOptions._toggleBuiltinRow(
+ getProfile(),
+ rowData.viewerKey,
+ rowData.index,
+ rowData.stackKey or displayEntry.stackKey
+ )
+ end)
+ end
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ delText = "+"
+ delTex = ACTION_BUTTON_TEXTURES.show
+ delTip = L["ADD_ENTRY"]
+ delAction = function()
+ doAction(function()
+ ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
+ end)
+ end
+ end
+
+ return {
+ label = ExtraIconsOptions._getEntryName(displayEntry),
+ icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
+ alpha = rowData.isDisabled and 0.55 or 1,
+ labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
+ labelColor = rowData.isDisabled and { 0.65, 0.65, 0.65, 1 } or { 1, 0.82, 0, 1 },
+ iconDesaturated = rowData.isDisabled == true,
+ iconVertexColor = rowData.isDisabled and { 0.6, 0.6, 0.6, 1 } or nil,
+ onEnter = function(owner)
+ showRowTooltip(owner, rowData)
+ end,
+ onLeave = function()
+ GameTooltip_Hide()
+ end,
+ actions = {
+ up = makeAction(
+ "^",
+ ACTION_BUTTON_TEXTURES.moveUp,
+ canReorder and rowData.activeIndex > 1,
+ L["MOVE_UP_TOOLTIP"],
+ function()
+ doAction(function()
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
+ end)
+ end
+ ),
+ down = makeAction(
+ "v",
+ ACTION_BUTTON_TEXTURES.moveDown,
+ canReorder and rowData.activeIndex < rowData.activeCount,
+ L["MOVE_DOWN_TOOLTIP"],
+ function()
+ doAction(function()
+ ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
+ end)
+ end
+ ),
+ move = makeAction(
+ rowData.viewerKey == "utility" and ">" or "<",
+ ACTION_BUTTON_TEXTURES.swap,
+ canMove,
+ function()
+ if hasMoveDup then
+ return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
+ end
+ if posLocked then
+ return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
+ end
+ return L["MOVE_TO_VIEWER_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
+ end,
+ function()
+ doAction(function()
+ ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
+ end)
+ end
+ ),
+ delete = makeAction(delText, delTex, not controlsDisabled, delTip, delAction),
+ },
+ }
+end
+
+local function buildModeInputTrailer(viewerKey)
+ local ds = draftStates[viewerKey]
+
+ local function getPreviewState()
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ local isDup, dupViewer = getDraftDuplicateInfo(viewerKey)
+ return status, name, icon, isDup, dupViewer
+ end
+
+ local function toggleKind()
+ if isDisabled() then
+ return false
+ end
+ ds.kind = ds.kind == "spell" and "item" or "spell"
+ return true
+ end
+
+ return {
+ type = "modeInput",
+ disabled = isDisabled,
+ modeText = function()
+ return ds.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"]
+ end,
+ modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
+ inputText = function()
+ return ds.idText
+ end,
+ placeholder = function()
+ return ds.kind == "spell" and L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] or L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
+ end,
+ previewIcon = function()
+ local _, _, icon = getPreviewState()
+ return icon
+ end,
+ previewText = function()
+ local status, name, _, isDup, dupViewer = getPreviewState()
+ if status == "resolved" and isDup then
+ return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(dupViewer))
+ end
+ if status == "resolved" then
+ return name or ""
+ end
+ if status == "pending" then
+ return "..."
+ end
+ return nil
+ end,
+ submitText = L["ADD_ENTRY"],
+ submitTooltip = L["ADD_ENTRY"],
+ submitEnabled = function()
+ local status, _, _, isDup = getPreviewState()
+ return status == "resolved" and not isDup
+ end,
+ onToggleMode = toggleKind,
+ onTextChanged = function(text)
+ ds.idText = text or ""
+ end,
+ onSubmit = function()
+ if isDisabled() then
+ return false
+ end
+ return addDraftEntry(viewerKey)
+ end,
+ onTabPressed = toggleKind,
+ }
+end
+
+function Util.BuildSections()
+ local viewers = getViewers()
+ local sections = {}
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ local items = {}
+ for _, rowData in ipairs(ExtraIconsOptions._buildViewerRows(viewers, viewerKey)) do
+ items[#items + 1] = buildActionItem(rowData)
+ end
+ sections[#sections + 1] = {
+ key = viewerKey,
+ title = VIEWER_LABELS[viewerKey],
+ items = items,
+ emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
+ footer = buildModeInputTrailer(viewerKey),
+ }
+ end
+ return sections
+end
+
+function Util.ResetToDefaults()
+ local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ if not (defaults and defaults.extraIcons) then
+ return
+ end
+
+ ns.Addon.db.profile.extraIcons = ns.CloneValue(defaults.extraIcons)
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey].kind = "spell"
+ draftStates[viewerKey].idText = ""
+ end
+ doAction()
+end
diff --git a/UI/GeneralOptions.lua b/UI/GeneralOptions.lua
index 3b97f661..e4e952a3 100644
--- a/UI/GeneralOptions.lua
+++ b/UI/GeneralOptions.lua
@@ -5,179 +5,165 @@
local _, ns = ...
local L = ns.L
local LSMW = LibStub("LibLSMSettingsWidgets-1.0")
-local getGlobalConfig = ns.GetGlobalConfig or function()
- local db = ns.Addon and ns.Addon.db
- local profile = db and db.profile
- return profile and profile.global
-end
-
-local GeneralOptions = {}
-
-function GeneralOptions.RegisterSettings(SB)
- SB.RegisterPage({
- name = L["GENERAL"],
- path = "global",
- rows = {
- -- Visibility
- { type = "header", name = L["VISIBILITY"] },
- {
- type = "checkbox",
- path = "hideWhenMounted",
- name = L["HIDE_WHEN_MOUNTED"],
- desc = L["HIDE_WHEN_MOUNTED_DESC"],
- },
- {
- type = "checkbox",
- path = "hideOutOfCombatInRestAreas",
- name = L["HIDE_IN_REST_AREAS"],
- desc = L["HIDE_IN_REST_AREAS_DESC"],
- },
- {
- id = "fade",
- type = "checkbox",
- path = "global.outOfCombatFade.enabled",
- name = L["FADE_OUT_OF_COMBAT"],
- desc = L["FADE_OUT_OF_COMBAT_DESC"],
- },
- {
- type = "slider",
- path = "global.outOfCombatFade.opacity",
- name = L["OUT_OF_COMBAT_OPACITY"],
- desc = L["OUT_OF_COMBAT_OPACITY_DESC"],
- min = 0,
- max = 100,
- step = 5,
- parent = "fade",
- },
- {
- type = "checkbox",
- path = "global.outOfCombatFade.exceptInInstance",
- name = L["EXCEPT_INSIDE_INSTANCES"],
- parent = "fade",
- },
- {
- type = "checkbox",
- path = "global.outOfCombatFade.exceptIfTargetCanBeAttacked",
- name = L["EXCEPT_TARGET_HOSTILE"],
- parent = "fade",
- },
- {
- type = "checkbox",
- path = "global.outOfCombatFade.exceptIfTargetCanBeHelped",
- name = L["EXCEPT_TARGET_FRIENDLY"],
- parent = "fade",
- },
-
- -- Appearance
- { type = "header", name = L["APPEARANCE"] },
- {
- type = "custom",
- path = "texture",
- name = L["BAR_TEXTURE"],
- desc = L["BAR_TEXTURE_DESC"],
- template = LSMW.TEXTURE_PICKER_TEMPLATE,
- },
- {
- type = "custom",
- path = "font",
- name = L["FONT"],
- desc = L["FONT_DESC"],
- template = LSMW.FONT_PICKER_TEMPLATE,
- },
- {
- type = "slider",
- path = "fontSize",
- name = L["FONT_SIZE"],
- min = 6,
- max = 32,
- step = 1,
- getTransform = function(value)
- return value or 11
- end,
- },
- {
- type = "dropdown",
- path = "fontOutline",
- name = L["FONT_OUTLINE"],
- values = {
- NONE = L["FONT_OUTLINE_NONE"],
- OUTLINE = L["FONT_OUTLINE_OUTLINE"],
- THICKOUTLINE = L["FONT_OUTLINE_THICK"],
- MONOCHROME = L["FONT_OUTLINE_MONOCHROME"],
- },
- },
- {
- type = "checkbox",
- path = "fontShadow",
- name = L["FONT_SHADOW"],
- desc = L["FONT_SHADOW_DESC"],
- },
+local GeneralOptions = {
+ key = "general",
+ name = L["GENERAL"],
+ path = "global",
+ rows = {
+ -- Visibility
+ { type = "header", name = L["VISIBILITY"] },
+ {
+ type = "checkbox",
+ path = "hideWhenMounted",
+ name = L["HIDE_WHEN_MOUNTED"],
+ desc = L["HIDE_WHEN_MOUNTED_DESC"],
+ },
+ {
+ type = "checkbox",
+ path = "hideOutOfCombatInRestAreas",
+ name = L["HIDE_IN_REST_AREAS"],
+ desc = L["HIDE_IN_REST_AREAS_DESC"],
+ },
+ {
+ id = "fade",
+ type = "checkbox",
+ path = "global.outOfCombatFade.enabled",
+ name = L["FADE_OUT_OF_COMBAT"],
+ desc = L["FADE_OUT_OF_COMBAT_DESC"],
+ },
+ {
+ type = "slider",
+ path = "global.outOfCombatFade.opacity",
+ name = L["OUT_OF_COMBAT_OPACITY"],
+ desc = L["OUT_OF_COMBAT_OPACITY_DESC"],
+ min = 0,
+ max = 100,
+ step = 5,
+ parent = "fade",
+ },
+ {
+ type = "checkbox",
+ path = "global.outOfCombatFade.exceptInInstance",
+ name = L["EXCEPT_INSIDE_INSTANCES"],
+ parent = "fade",
+ },
+ {
+ type = "checkbox",
+ path = "global.outOfCombatFade.exceptIfTargetCanBeAttacked",
+ name = L["EXCEPT_TARGET_HOSTILE"],
+ parent = "fade",
+ },
+ {
+ type = "checkbox",
+ path = "global.outOfCombatFade.exceptIfTargetCanBeHelped",
+ name = L["EXCEPT_TARGET_FRIENDLY"],
+ parent = "fade",
+ },
- -- Sizing
- { type = "header", name = L["SIZING"] },
- {
- type = "slider",
- path = "barHeight",
- name = L["BAR_HEIGHT"],
- desc = L["BAR_HEIGHT_DESC"],
- min = 10,
- max = 40,
- step = 1,
+ -- Appearance
+ { type = "header", name = L["APPEARANCE"] },
+ {
+ type = "custom",
+ path = "texture",
+ name = L["BAR_TEXTURE"],
+ desc = L["BAR_TEXTURE_DESC"],
+ template = LSMW.TEXTURE_PICKER_TEMPLATE,
+ },
+ {
+ type = "custom",
+ path = "font",
+ name = L["FONT"],
+ desc = L["FONT_DESC"],
+ template = LSMW.FONT_PICKER_TEMPLATE,
+ },
+ {
+ type = "slider",
+ path = "fontSize",
+ name = L["FONT_SIZE"],
+ min = 6,
+ max = 32,
+ step = 1,
+ getTransform = function(value)
+ return value or 11
+ end,
+ },
+ {
+ type = "dropdown",
+ path = "fontOutline",
+ name = L["FONT_OUTLINE"],
+ values = {
+ NONE = L["FONT_OUTLINE_NONE"],
+ OUTLINE = L["FONT_OUTLINE_OUTLINE"],
+ THICKOUTLINE = L["FONT_OUTLINE_THICK"],
+ MONOCHROME = L["FONT_OUTLINE_MONOCHROME"],
},
},
- })
-end
-
-ns.SettingsBuilder.RegisterSection(ns, "General", GeneralOptions)
-
-local AdvancedOptions = {}
+ {
+ type = "checkbox",
+ path = "fontShadow",
+ name = L["FONT_SHADOW"],
+ desc = L["FONT_SHADOW_DESC"],
+ },
-function AdvancedOptions.RegisterSettings(SB)
- SB.RegisterPage({
- name = L["ADVANCED_OPTIONS"],
- path = "global",
- rows = {
- { type = "header", name = L["TROUBLESHOOTING"] },
- {
- type = "checkbox",
- path = "debug",
- name = L["DEBUG_MODE"],
- desc = L["DEBUG_MODE_DESC"],
- },
- {
- type = "checkbox",
- path = "debugToChat",
- name = L["DEBUG_TO_CHAT"],
- desc = L["DEBUG_TO_CHAT_DESC"],
- disabled = function()
- local gc = getGlobalConfig()
- return not (gc and gc.debug)
- end,
- },
- { type = "header", name = L["UPDATES"] },
- {
- type = "button",
- name = " ",
- buttonText = L["SHOW_WHATS_NEW"],
- tooltip = L["SHOW_WHATS_NEW_DESC"],
- onClick = function()
- if ns.Addon and type(ns.Addon.ShowReleasePopup) == "function" then
- ns.Addon:ShowReleasePopup(true)
- end
- end,
- },
- { type = "header", name = L["PERFORMANCE"] },
- {
- type = "slider",
- path = "updateFrequency",
- name = L["UPDATE_FREQUENCY"],
- desc = L["UPDATE_FREQUENCY_DESC"],
- min = 0.04,
- max = 0.5,
- step = 0.02,
- },
+ -- Sizing
+ { type = "header", name = L["SIZING"] },
+ {
+ type = "slider",
+ path = "barHeight",
+ name = L["BAR_HEIGHT"],
+ desc = L["BAR_HEIGHT_DESC"],
+ min = 10,
+ max = 40,
+ step = 1,
},
- })
-end
+ },
+}
+ns.GeneralOptions = GeneralOptions
-ns.SettingsBuilder.RegisterSection(ns, "Advanced Options", AdvancedOptions)
+local AdvancedOptions = {
+ key = "advancedOptions",
+ name = L["ADVANCED_OPTIONS"],
+ path = "global",
+ rows = {
+ { type = "header", name = L["TROUBLESHOOTING"] },
+ {
+ type = "checkbox",
+ path = "debug",
+ name = L["DEBUG_MODE"],
+ desc = L["DEBUG_MODE_DESC"],
+ },
+ {
+ type = "checkbox",
+ path = "debugToChat",
+ name = L["DEBUG_TO_CHAT"],
+ desc = L["DEBUG_TO_CHAT_DESC"],
+ disabled = function()
+ local gc = ns.GetGlobalConfig()
+ return not (gc and gc.debug)
+ end,
+ },
+ {
+ type = "header",
+ name = L["WHATS_NEW"],
+ },
+ {
+ type = "button",
+ name = " ",
+ buttonText = L["SHOW_WHATS_NEW"],
+ onClick = function()
+ ns.Addon:ShowReleasePopup(true)
+ end,
+ },
+ { type = "header", name = L["PERFORMANCE"] },
+ {
+ type = "slider",
+ path = "updateFrequency",
+ name = L["UPDATE_FREQUENCY"],
+ desc = L["UPDATE_FREQUENCY_DESC"],
+ min = 0.04,
+ max = 0.5,
+ step = 0.02,
+ },
+ },
+}
+ns.AdvancedOptions = AdvancedOptions
diff --git a/UI/LayoutOptions.lua b/UI/LayoutOptions.lua
index 82c24447..6e807039 100644
--- a/UI/LayoutOptions.lua
+++ b/UI/LayoutOptions.lua
@@ -7,6 +7,7 @@ local C = ns.Constants
local L = ns.L
local LayoutOptions = {}
+ns.LayoutOptions = LayoutOptions
local function createAnchorModeSpec(name, path, disabled)
return {
@@ -23,78 +24,74 @@ local function createAnchorModeSpec(name, path, disabled)
}
end
-function LayoutOptions.RegisterSettings(SB)
- local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
- local defaultDetachedGrowDirection = ns.OptionUtil.CreateDefaultValueTransform(C.GROW_DIRECTION_DOWN)
- local powerBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("powerBar")
- local resourceBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("resourceBar")
- local runeBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
- local buffBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
+local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
+local defaultDetachedGrowDirection = ns.OptionUtil.CreateDefaultValueTransform(C.GROW_DIRECTION_DOWN)
+local powerBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("powerBar")
+local resourceBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("resourceBar")
+local runeBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
+local buffBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
- local rows = {
- {
- type = "canvas",
- canvas = ns.OptionUtil.CreatePositioningExamplesCanvas(),
- height = C.POSITION_MODE_EXPLAINER_HEIGHT,
- },
- {
- type = "header",
- name = L["MODULE_LAYOUT_HEADER"],
- },
- createAnchorModeSpec(L["POWER_BAR"], "powerBar.anchorMode", powerBarDisabled),
- createAnchorModeSpec(L["RESOURCE_BAR"], "resourceBar.anchorMode", resourceBarDisabled),
- createAnchorModeSpec(L["RUNE_BAR"], "runeBar.anchorMode", runeBarDisabled),
- createAnchorModeSpec(L["AURA_BARS"], "buffBars.anchorMode", buffBarsDisabled),
- {
- type = "header",
- name = L["POSITION_MODE_ATTACHED"],
- },
- {
- type = "slider",
- path = "global.offsetY",
- name = L["VERTICAL_OFFSET"],
- desc = L["VERTICAL_OFFSET_DESC"],
- min = 0,
- max = 20,
- step = 1,
- },
- {
- type = "slider",
- path = "global.moduleSpacing",
- name = L["VERTICAL_SPACING"],
- desc = L["VERTICAL_SPACING_DESC"],
- min = 0,
- max = 20,
- step = 1,
- getTransform = defaultZero,
- },
- {
- type = "dropdown",
- path = "global.moduleGrowDirection",
- name = L["GROW_DIRECTION"],
- desc = L["GROW_DIRECTION_ATTACHED_DESC"],
- values = {
- [C.GROW_DIRECTION_DOWN] = L["DOWN"],
- [C.GROW_DIRECTION_UP] = L["UP"],
- },
- getTransform = defaultDetachedGrowDirection,
+local rows = {
+ {
+ type = "canvas",
+ canvas = ns.OptionUtil.CreatePositioningExamplesCanvas(),
+ height = C.POSITION_MODE_EXPLAINER_HEIGHT,
+ },
+ {
+ type = "header",
+ name = L["MODULE_LAYOUT_HEADER"],
+ },
+ createAnchorModeSpec(L["POWER_BAR"], "powerBar.anchorMode", powerBarDisabled),
+ createAnchorModeSpec(L["RESOURCE_BAR"], "resourceBar.anchorMode", resourceBarDisabled),
+ createAnchorModeSpec(L["RUNE_BAR"], "runeBar.anchorMode", runeBarDisabled),
+ createAnchorModeSpec(L["AURA_BARS"], "buffBars.anchorMode", buffBarsDisabled),
+ {
+ type = "header",
+ name = L["POSITION_MODE_ATTACHED"],
+ },
+ {
+ type = "slider",
+ path = "global.offsetY",
+ name = L["VERTICAL_OFFSET"],
+ desc = L["VERTICAL_OFFSET_DESC"],
+ min = 0,
+ max = 20,
+ step = 1,
+ },
+ {
+ type = "slider",
+ path = "global.moduleSpacing",
+ name = L["VERTICAL_SPACING"],
+ desc = L["VERTICAL_SPACING_DESC"],
+ min = 0,
+ max = 20,
+ step = 1,
+ getTransform = defaultZero,
+ },
+ {
+ type = "dropdown",
+ path = "global.moduleGrowDirection",
+ name = L["GROW_DIRECTION"],
+ desc = L["GROW_DIRECTION_ATTACHED_DESC"],
+ values = {
+ [C.GROW_DIRECTION_DOWN] = L["DOWN"],
+ [C.GROW_DIRECTION_UP] = L["UP"],
},
- }
-
- for _, row in ipairs(ns.OptionUtil.CreateDetachedStackRows()) do
- rows[#rows + 1] = row
- end
+ getTransform = defaultDetachedGrowDirection,
+ },
+}
- SB.RegisterPage({
- name = L["LAYOUT_SUBCATEGORY"],
- onShow = function()
- ns.Runtime.SetLayoutPreview(true)
- end,
- onHide = function()
- ns.Runtime.SetLayoutPreview(false)
- end,
- rows = rows,
- })
+for _, row in ipairs(ns.OptionUtil.CreateDetachedStackRows()) do
+ rows[#rows + 1] = row
end
-ns.SettingsBuilder.RegisterSection(ns, "Layout", LayoutOptions)
+LayoutOptions.key = "layout"
+LayoutOptions.name = L["LAYOUT_SUBCATEGORY"]
+LayoutOptions.path = ""
+LayoutOptions.onShow = function()
+ ns.Runtime.SetLayoutPreview(true)
+end
+LayoutOptions.onHide = function()
+ ns.Runtime.SetLayoutPreview(false)
+end
+LayoutOptions.rows = rows
diff --git a/UI/OptionUtil.lua b/UI/OptionUtil.lua
index 0ad7e350..8a10268d 100644
--- a/UI/OptionUtil.lua
+++ b/UI/OptionUtil.lua
@@ -125,7 +125,10 @@ function OptionUtil.CreatePositioningExamplesCanvas()
end
function OptionUtil.OpenLayoutPage()
- local categoryID = ns.SettingsBuilder.GetSubcategoryID(L["LAYOUT_SUBCATEGORY"])
+ local root = ns.Settings
+ local section = root and root:GetSection("layout")
+ local page = section and section:GetPage("main")
+ local categoryID = page and page:GetID()
if categoryID then
Settings.OpenToCategory(categoryID)
end
@@ -298,7 +301,7 @@ function OptionUtil.CreateModuleEnabledHandler(moduleName, requiresReload)
end
--- Generates standard layout and appearance rows shared by bar-type modules.
---- This is the canonical rows-array form used by RegisterPage pages.
+--- This is the canonical rows-array form used by declarative section/page specs.
---@param isDisabled fun(): boolean
---@param options table|nil { showText: boolean, border: boolean }
---@return table[] rows
diff --git a/UI/Options.lua b/UI/Options.lua
index 5242912b..f56d89f3 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -22,7 +22,8 @@ local GITHUB_URL = "https://github.com/argium/EnhancedCooldownManager"
local LSB = LibStub("LibSettingsBuilder-1.0")
-ns.SettingsBuilder = LSB:New({
+ns.Settings = LSB:New({
+ name = L["ADDON_NAME"],
pathAdapter = LSB.PathAdapter({
getStore = function()
return ns.Addon.db and ns.Addon.db.profile
@@ -54,72 +55,68 @@ ns.SettingsBuilder = LSB:New({
},
},
})
+ns.SettingsBuilder = ns.Settings
--------------------------------------------------------------------------------
-- About section
--------------------------------------------------------------------------------
-local About = {}
-
-function About.RegisterSettings(SB)
- local version = (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
- local authorText = ns.ColorUtil.Sparkle("Argi")
-
- SB.RegisterPage({
- name = L["ADDON_NAME"],
- rootCategory = true,
- rows = {
- {
- type = "info",
- name = L["AUTHOR"],
- value = authorText,
- },
- {
- type = "info",
- name = L["CONTRIBUTORS"],
- value = "kayti-wow",
- },
- {
- type = "info",
- name = L["VERSION"],
- value = version,
- },
- {
- type = "subheader",
- name = L["LINKS"],
- },
- {
- type = "button",
- name = L["CURSEFORGE"],
- buttonText = L["CURSEFORGE"],
- onClick = function()
- ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
- end,
- },
- {
- type = "button",
- name = L["GITHUB"],
- buttonText = L["GITHUB"],
- onClick = function()
- ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
- end,
- },
- },
- })
+local function getAddonVersion()
+ return (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
end
+ns.AboutPage = {
+ key = "about",
+ rows = {
+ {
+ type = "info",
+ name = L["AUTHOR"],
+ value = function()
+ return ns.ColorUtil.Sparkle("Argi")
+ end,
+ },
+ {
+ type = "info",
+ name = L["CONTRIBUTORS"],
+ value = "kayti-wow",
+ },
+ {
+ type = "info",
+ name = L["VERSION"],
+ value = getAddonVersion,
+ },
+ {
+ type = "subheader",
+ name = L["LINKS"],
+ },
+ {
+ type = "button",
+ name = L["CURSEFORGE"],
+ buttonText = L["CURSEFORGE"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
+ end,
+ },
+ {
+ type = "button",
+ name = L["GITHUB"],
+ buttonText = L["GITHUB"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
+ end,
+ },
+ },
+}
+
--------------------------------------------------------------------------------
-- Options module
--------------------------------------------------------------------------------
-ns.OptionsSections = ns.OptionsSections or {}
-ns.OptionsSections["About"] = About
-
local Options = ns.Addon:NewModule("Options")
local function isTrackedECMCategory(category)
- local SB = ns.SettingsBuilder
- return SB ~= nil and SB.HasCategory(category)
+ local root = ns.Settings
+ return root ~= nil and root:HasCategory(category)
end
local function getCategoryOpenToken(category)
@@ -140,9 +137,14 @@ local function rememberTrackedCategory(module, category)
end
local function getDefaultOptionsCategoryToken()
- local SB = ns.SettingsBuilder
- local category = SB.GetSubcategory(L["GENERAL"]) or SB.GetRootCategory()
- return getCategoryOpenToken(category)
+ local root = ns.Settings
+ local section = root and root:GetSection("general")
+ local page = section and section:GetPage("main")
+ if page then
+ return page:GetID()
+ end
+
+ return nil
end
function Options:InstallCategoryTracking()
@@ -178,33 +180,21 @@ function Options:InstallCategoryTracking()
end
function Options:OnInitialize()
- local SB = ns.SettingsBuilder
- SB.CreateRootCategory(L["ADDON_NAME"])
-
- -- About section renders on the root category (no subcategory entry)
- ns.OptionsSections["About"].RegisterSettings(SB)
-
- -- Register subcategory sections in display order
- local sectionOrder = {
- "General",
- "Layout",
- "PowerBar",
- "ResourceBar",
- "RuneBar",
- "BuffBars",
- "ExtraIcons",
- "Profile",
- "Advanced Options",
- }
-
- for _, key in ipairs(sectionOrder) do
- local section = ns.OptionsSections[key]
- if section and section.RegisterSettings then
- section.RegisterSettings(SB)
- end
- end
+ ns.Settings:Register({
+ page = ns.AboutPage,
+ sections = {
+ ns.GeneralOptions,
+ ns.LayoutOptions,
+ ns.PowerBarOptions,
+ ns.ResourceBarOptions,
+ ns.RuneBarOptions,
+ ns.BuffBarsOptions,
+ ns.ExtraIconsOptions,
+ ns.ProfileOptions,
+ ns.AdvancedOptions,
+ },
+ })
- SB.RegisterCategories()
self:InstallCategoryTracking()
end
diff --git a/UI/PowerBarOptions.lua b/UI/PowerBarOptions.lua
index fe777934..b9fced6b 100644
--- a/UI/PowerBarOptions.lua
+++ b/UI/PowerBarOptions.lua
@@ -18,39 +18,43 @@ local POWER_COLOR_DEFS = {
}
local PowerBarOptions = {}
+ns.PowerBarOptions = PowerBarOptions
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("powerBar")
-function PowerBarOptions.RegisterSettings(SB)
- local rows = {
- {
- type = "checkbox",
- path = "enabled",
- name = L["ENABLE_POWER_BAR"],
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar"),
- },
- }
-
- for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
- rows[#rows + 1] = row
- end
-
- rows[#rows + 1] = {
+local rows = {
+ {
type = "checkbox",
- path = "showManaAsPercent",
- name = L["SHOW_MANA_AS_PERCENT"],
- desc = L["SHOW_MANA_AS_PERCENT_DESC"],
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "colorList",
- path = "colors",
- label = L["COLORS"],
- defs = POWER_COLOR_DEFS,
- disabled = isDisabled,
- }
+ path = "enabled",
+ name = L["ENABLE_POWER_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar"),
+ },
+}
- SB.RegisterPage({ name = L["POWER_BAR"], path = "powerBar", rows = rows })
- ns.PowerBarTickMarksOptions.RegisterSettings(SB, SB._currentSubcategory)
+for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
+ rows[#rows + 1] = row
end
-ns.SettingsBuilder.RegisterSection(ns, "PowerBar", PowerBarOptions)
+rows[#rows + 1] = {
+ type = "checkbox",
+ path = "showManaAsPercent",
+ name = L["SHOW_MANA_AS_PERCENT"],
+ desc = L["SHOW_MANA_AS_PERCENT_DESC"],
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "colorList",
+ path = "colors",
+ label = L["COLORS"],
+ defs = POWER_COLOR_DEFS,
+ disabled = isDisabled,
+}
+
+PowerBarOptions.key = "powerBar"
+PowerBarOptions.name = L["POWER_BAR"]
+PowerBarOptions.pages = {
+ {
+ key = "main",
+ rows = rows,
+ },
+ ns.PowerBarTickMarksOptions,
+}
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index 4f6dc920..88b94b53 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -5,107 +5,112 @@
local _, ns = ...
local C = ns.Constants
local L = ns.L
-local store = ns.PowerBarTickMarksStore or {}
local PowerBarTickMarksOptions = ns.PowerBarTickMarksOptions or {}
-ns.PowerBarTickMarksStore = store
ns.PowerBarTickMarksOptions = PowerBarTickMarksOptions
local function getPowerBarConfig()
local profile = ns.Addon.db.profile
local powerBar = profile.powerBar
- if powerBar then
- return powerBar
+ if not powerBar then
+ powerBar = {}
+ profile.powerBar = powerBar
end
-
- powerBar = {}
- profile.powerBar = powerBar
return powerBar
end
local function getTicksConfig()
local powerBar = getPowerBarConfig()
- if powerBar.ticks then
- return powerBar.ticks
+ local ticks = powerBar.ticks
+ if not ticks then
+ ticks = {
+ mappings = {},
+ defaultColor = C.DEFAULT_POWERBAR_TICK_COLOR,
+ defaultWidth = 1,
+ }
+ powerBar.ticks = ticks
end
-
- powerBar.ticks = {
- mappings = {},
- defaultColor = C.DEFAULT_POWERBAR_TICK_COLOR,
- defaultWidth = 1,
- }
- return powerBar.ticks
+ return ticks
end
-function store.GetCurrentTicks()
+local function getCurrentTicks()
local classID, specIndex = ns.OptionUtil.GetCurrentClassSpec()
if not classID or not specIndex then
return {}
end
- local mappings = getTicksConfig().mappings
- local classMappings = mappings and mappings[classID]
+ local classMappings = getTicksConfig().mappings[classID]
return classMappings and classMappings[specIndex] or {}
end
-function store.SetCurrentTicks(ticks)
+local function setCurrentTicks(ticks)
local classID, specIndex = ns.OptionUtil.GetCurrentClassSpec()
if not classID or not specIndex then
return
end
local ticksCfg = getTicksConfig()
- if not ticksCfg.mappings[classID] then
- ticksCfg.mappings[classID] = {}
+ local classMappings = ticksCfg.mappings[classID]
+ if not classMappings then
+ classMappings = {}
+ ticksCfg.mappings[classID] = classMappings
end
- ticksCfg.mappings[classID][specIndex] = ticks
+ classMappings[specIndex] = ticks
end
-function store.AddTick(value, color, width)
- local ticks = store.GetCurrentTicks()
+local function addTick(value, color, width)
+ local ticks = getCurrentTicks()
local ticksCfg = getTicksConfig()
ticks[#ticks + 1] = {
value = value,
color = color or ns.CloneValue(ticksCfg.defaultColor),
width = width or ticksCfg.defaultWidth,
}
- store.SetCurrentTicks(ticks)
+ setCurrentTicks(ticks)
end
-function store.RemoveTick(index)
- local ticks = store.GetCurrentTicks()
+local function removeTick(index)
+ local ticks = getCurrentTicks()
if not ticks[index] then
return
end
table.remove(ticks, index)
- store.SetCurrentTicks(ticks)
+ setCurrentTicks(ticks)
end
-function store.UpdateTick(index, field, value)
- local ticks = store.GetCurrentTicks()
+local function updateTick(index, field, value)
+ local ticks = getCurrentTicks()
if not ticks[index] then
return
end
ticks[index][field] = value
- store.SetCurrentTicks(ticks)
+ setCurrentTicks(ticks)
end
-function store.GetDefaultColor()
+local function getDefaultColor()
return getTicksConfig().defaultColor
end
-function store.SetDefaultColor(color)
+local function setDefaultColor(color)
getTicksConfig().defaultColor = color
end
-function store.GetDefaultWidth()
+local function getDefaultWidth()
return getTicksConfig().defaultWidth
end
-function store.SetDefaultWidth(width)
+local function setDefaultWidth(width)
getTicksConfig().defaultWidth = width
end
StaticPopupDialogs["ECM_CONFIRM_CLEAR_TICKS"] = ns.OptionUtil.MakeConfirmDialog(L["TICK_MARKS_CLEAR_CONFIRM"])
+local registeredPage
+
+local function refreshPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+end
+
local function getValueSliderRange(currentValue)
for _, tier in ipairs(C.VALUE_SLIDER_TIERS) do
if currentValue <= tier.ceiling then
@@ -116,203 +121,189 @@ local function getValueSliderRange(currentValue)
return math.ceil(currentValue / last.step) * last.step, last.step
end
-function PowerBarTickMarksOptions.RegisterSettings(SB, parentCategory)
- local categoryName = "Tick Marks"
- local category
-
- local function refreshCategory()
- if category then
- SB.RefreshCategory(category)
- else
- SB.RefreshCategory(categoryName)
- end
- end
-
- local function scheduleUpdate()
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- end
+local function scheduleUpdate()
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+end
- local function clearAllTicks()
- store.SetCurrentTicks({})
- scheduleUpdate()
- refreshCategory()
- end
+local function clearAllTicks()
+ setCurrentTicks({})
+ scheduleUpdate()
+ refreshPage()
+end
- local function getTickSummary()
- local _, _, localisedClassName, specName, className = ns.OptionUtil.GetCurrentClassSpec()
- local color = C.CLASS_COLORS[className] or C.COLOR_WHITE_HEX
- local classSpecLabel = "|cff" .. color .. (localisedClassName or "Unknown") .. "|r " .. (specName or "Unknown")
- local ticks = store.GetCurrentTicks()
- local count = #ticks
- if count == 0 then
- return string.format(L["NO_TICK_MARKS"], classSpecLabel)
- end
- return string.format(L["TICK_COUNT"], classSpecLabel, count)
+local function getTickSummary()
+ local _, _, localisedClassName, specName, className = ns.OptionUtil.GetCurrentClassSpec()
+ local color = C.CLASS_COLORS[className] or C.COLOR_WHITE_HEX
+ local classSpecLabel = "|cff" .. color .. (localisedClassName or "Unknown") .. "|r " .. (specName or "Unknown")
+ local ticks = getCurrentTicks()
+ local count = #ticks
+ if count == 0 then
+ return string.format(L["NO_TICK_MARKS"], classSpecLabel)
end
+ return string.format(L["TICK_COUNT"], classSpecLabel, count)
+end
- local function buildTickCollectionItems()
- local ticks = store.GetCurrentTicks()
- local items = {}
+local function buildTickCollectionItems()
+ local ticks = getCurrentTicks()
+ local items = {}
- for index, tick in ipairs(ticks) do
- items[#items + 1] = {
- label = string.format(L["TICK_N"], index),
- fields = {
- {
- value = tick.value or 50,
- min = 1,
- max = 200,
- step = 1,
- sliderWidth = 150,
- valueWidth = 50,
- editWidth = 60,
- getRange = function(_, targetValue)
- local ceiling, step = getValueSliderRange(math.max(1, targetValue or tick.value or 50))
- return 1, ceiling, step
- end,
- onValueChanged = function(rounded)
- store.UpdateTick(index, "value", rounded)
- scheduleUpdate()
- refreshCategory()
- end,
- },
- {
- value = tick.width or store.GetDefaultWidth(),
- min = 1,
- max = 5,
- step = 1,
- sliderWidth = 90,
- valueWidth = 18,
- editWidth = 34,
- onValueChanged = function(rounded)
- store.UpdateTick(index, "width", rounded)
- scheduleUpdate()
- refreshCategory()
- end,
- },
- },
- color = {
- value = tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR,
- onClick = function()
- local current = tick.color or store.GetDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
- ns.OptionUtil.OpenColorPicker(current, true, function(color)
- store.UpdateTick(index, "color", color)
- scheduleUpdate()
- refreshCategory()
- end)
+ for index, tick in ipairs(ticks) do
+ items[#items + 1] = {
+ label = string.format(L["TICK_N"], index),
+ fields = {
+ {
+ value = tick.value or 50,
+ min = 1,
+ max = 200,
+ step = 1,
+ sliderWidth = 150,
+ valueWidth = 50,
+ editWidth = 60,
+ getRange = function(_, targetValue)
+ local ceiling, step = getValueSliderRange(math.max(1, targetValue or tick.value or 50))
+ return 1, ceiling, step
end,
- },
- remove = {
- text = L["REMOVE"],
- onClick = function()
- store.RemoveTick(index)
+ onValueChanged = function(rounded)
+ updateTick(index, "value", rounded)
scheduleUpdate()
- refreshCategory()
+ refreshPage()
end,
},
- }
- end
-
- return items
- end
-
- SB.RegisterPage({
- name = categoryName,
- parentCategory = parentCategory,
- rows = {
- {
- id = "tickMarksPageActions",
- type = "pageActions",
- name = categoryName,
- actions = {
- {
- text = SETTINGS_DEFAULTS,
- width = 100,
- enabled = function()
- return #store.GetCurrentTicks() > 0
- end,
- onClick = function()
- StaticPopup_Show("ECM_CONFIRM_CLEAR_TICKS", nil, nil, {
- onAccept = clearAllTicks,
- })
- end,
- },
+ {
+ value = tick.width or getDefaultWidth(),
+ min = 1,
+ max = 5,
+ step = 1,
+ sliderWidth = 90,
+ valueWidth = 18,
+ editWidth = 34,
+ onValueChanged = function(rounded)
+ updateTick(index, "width", rounded)
+ scheduleUpdate()
+ refreshPage()
+ end,
},
},
- {
- id = "description",
- type = "info",
- name = "",
- value = L["TICK_MARKS_DESC"],
- wide = true,
- multiline = true,
- height = 36,
- },
- {
- id = "summary",
- type = "info",
- name = "",
- value = getTickSummary,
- wide = true,
- multiline = true,
- height = 28,
- },
- {
- id = "defaultColor",
- type = "color",
- key = "tickMarksDefaultColor",
- name = L["DEFAULT_COLOR"],
- default = C.DEFAULT_POWERBAR_TICK_COLOR,
- get = function()
- return store.GetDefaultColor()
- end,
- set = function(color)
- store.SetDefaultColor(color)
- end,
- onSet = function()
- refreshCategory()
- end,
- },
- {
- id = "defaultWidth",
- type = "slider",
- key = "tickMarksDefaultWidth",
- name = L["DEFAULT_WIDTH"],
- default = 1,
- min = 1,
- max = 5,
- step = 1,
- get = function()
- return store.GetDefaultWidth()
- end,
- set = function(width)
- store.SetDefaultWidth(width)
- end,
- onSet = function()
- refreshCategory()
+ color = {
+ value = tick.color or getDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR,
+ onClick = function()
+ local current = tick.color or getDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
+ ns.OptionUtil.OpenColorPicker(current, true, function(color)
+ updateTick(index, "color", color)
+ scheduleUpdate()
+ refreshPage()
+ end)
end,
},
- {
- id = "addTick",
- type = "button",
- name = L["ADD_TICK_MARK"],
- buttonText = L["ADD"],
+ remove = {
+ text = L["REMOVE"],
onClick = function()
- store.AddTick(50, nil, nil)
+ removeTick(index)
scheduleUpdate()
- refreshCategory()
+ refreshPage()
end,
},
+ }
+ end
+
+ return items
+end
+
+PowerBarTickMarksOptions.key = "tickMarks"
+PowerBarTickMarksOptions.name = "Tick Marks"
+PowerBarTickMarksOptions.onRegistered = function(page)
+ registeredPage = page
+end
+PowerBarTickMarksOptions.rows = {
+ {
+ id = "tickMarksPageActions",
+ type = "pageActions",
+ name = PowerBarTickMarksOptions.name,
+ actions = {
{
- id = "tickCollection",
- type = "list",
- variant = "editor",
- height = 320,
- rowHeight = C.SCROLL_ROW_HEIGHT_WITH_CONTROLS,
- items = buildTickCollectionItems,
+ text = SETTINGS_DEFAULTS,
+ width = 100,
+ enabled = function()
+ return #getCurrentTicks() > 0
+ end,
+ onClick = function()
+ StaticPopup_Show("ECM_CONFIRM_CLEAR_TICKS", nil, nil, {
+ onAccept = clearAllTicks,
+ })
+ end,
},
},
- })
-
- category = SB.GetSubcategory(categoryName)
-end
+ },
+ {
+ id = "description",
+ type = "info",
+ name = "",
+ value = L["TICK_MARKS_DESC"],
+ wide = true,
+ multiline = true,
+ height = 36,
+ },
+ {
+ id = "summary",
+ type = "info",
+ name = "",
+ value = getTickSummary,
+ wide = true,
+ multiline = true,
+ height = 28,
+ },
+ {
+ id = "defaultColor",
+ type = "color",
+ key = "tickMarksDefaultColor",
+ name = L["DEFAULT_COLOR"],
+ default = C.DEFAULT_POWERBAR_TICK_COLOR,
+ get = function()
+ return getDefaultColor()
+ end,
+ set = function(color)
+ setDefaultColor(color)
+ end,
+ onSet = function(_, _, page)
+ page:Refresh()
+ end,
+ },
+ {
+ id = "defaultWidth",
+ type = "slider",
+ key = "tickMarksDefaultWidth",
+ name = L["DEFAULT_WIDTH"],
+ default = 1,
+ min = 1,
+ max = 5,
+ step = 1,
+ get = function()
+ return getDefaultWidth()
+ end,
+ set = function(width)
+ setDefaultWidth(width)
+ end,
+ onSet = function(_, _, page)
+ page:Refresh()
+ end,
+ },
+ {
+ id = "addTick",
+ type = "button",
+ name = L["ADD_TICK_MARK"],
+ buttonText = L["ADD"],
+ onClick = function(page)
+ addTick(50, nil, nil)
+ scheduleUpdate()
+ page:Refresh()
+ end,
+ },
+ {
+ id = "tickCollection",
+ type = "list",
+ variant = "editor",
+ height = 320,
+ rowHeight = C.SCROLL_ROW_HEIGHT_WITH_CONTROLS,
+ items = buildTickCollectionItems,
+ },
+}
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index c2439be7..28fcd3d2 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -48,6 +48,7 @@ StaticPopupDialogs["ECM_CONFIRM_COPY_PROFILE"] = ns.OptionUtil.MakeConfirmDialog
StaticPopupDialogs["ECM_CONFIRM_DELETE_PROFILE"] = ns.OptionUtil.MakeConfirmDialog(L["DELETE_PROFILE_CONFIRM"])
local ProfileOptions = {}
+ns.ProfileOptions = ProfileOptions
local function getPreferredProfileSelection(valuesGenerator)
local values = valuesGenerator()
@@ -69,7 +70,7 @@ local function getPreferredProfileSelection(valuesGenerator)
end
--- Creates a handler-backed dropdown for transient profile selection (not stored in SavedVars).
-local function createProfilePicker(SB, cat, variable, name, tooltip, valuesGenerator)
+local function createProfilePickerRow(variable, name, tooltip, valuesGenerator)
local selected = getPreferredProfileSelection(valuesGenerator)
local function ensureSelection()
@@ -86,12 +87,14 @@ local function createProfilePicker(SB, cat, variable, name, tooltip, valuesGener
return map
end
- local _, setting = SB.Dropdown({
- category = cat,
+ ensureSelection()
+
+ return {
+ type = "dropdown",
key = variable,
name = name,
tooltip = tooltip,
- default = selected,
+ default = "",
scrollHeight = 240,
values = values,
get = function()
@@ -101,10 +104,7 @@ local function createProfilePicker(SB, cat, variable, name, tooltip, valuesGener
set = function(value)
selected = value
end,
- })
- ensureSelection()
-
- return setting, function()
+ }, function()
ensureSelection()
return selected
end, function()
@@ -112,21 +112,41 @@ local function createProfilePicker(SB, cat, variable, name, tooltip, valuesGener
end
end
-function ProfileOptions.RegisterSettings(SB)
- local cat = SB.CreateSubcategory(L["PROFILES"])
- local function refreshCategory()
- SB.RefreshCategory(cat)
+local function otherProfilesGenerator()
+ local container = Settings.CreateControlTextContainer()
+ local current = ns.Addon.db:GetCurrentProfile()
+ for _, name in ipairs(ns.Addon.db:GetProfiles()) do
+ if name ~= current then
+ container:Add(name, name)
+ end
end
+ return container:GetData()
+end
- -- Switch Profile
- SB.Header(L["ACTIVE_PROFILE"])
+local copyProfileRow, getCopyProfile, resetCopyProfile = createProfilePickerRow(
+ "ProfileCopy",
+ L["COPY_FROM"],
+ L["COPY_FROM_DESC"],
+ otherProfilesGenerator
+)
- local _, switchSetting = SB.Dropdown({
- category = cat,
+local deleteProfileRow, getDeleteProfile, resetDeleteProfile = createProfilePickerRow(
+ "ProfileDelete",
+ L["DELETE_PROFILE"],
+ L["DELETE_PROFILE_SELECT_DESC"],
+ otherProfilesGenerator
+)
+
+ProfileOptions.key = "profile"
+ProfileOptions.name = L["PROFILES"]
+ProfileOptions.rows = {
+ { type = "header", name = L["ACTIVE_PROFILE"] },
+ {
+ type = "dropdown",
key = "ProfileSwitch",
name = L["SWITCH_PROFILE"],
tooltip = L["SWITCH_PROFILE_DESC"],
- default = ns.Addon.db:GetCurrentProfile(),
+ default = "",
scrollHeight = 240,
values = function()
local values = {}
@@ -140,46 +160,33 @@ function ProfileOptions.RegisterSettings(SB)
end,
set = function(value)
ns.Addon.db:SetProfile(value)
- refreshCategory()
end,
- })
-
- SB.Button({
+ onSet = function(_, _, page)
+ page:Refresh()
+ end,
+ },
+ {
+ type = "button",
name = L["NEW_PROFILE"],
buttonText = L["NEW_PROFILE"],
tooltip = L["NEW_PROFILE_DESC"],
- onClick = function()
+ onClick = function(page)
StaticPopup_Show("ECM_NEW_PROFILE", nil, nil, {
onAccept = function(name)
- switchSetting:SetValue(name)
- refreshCategory()
+ ns.Addon.db:SetProfile(name)
+ page:Refresh()
end,
})
end,
- })
-
- -- Copy / Delete
- SB.Header(L["PROFILE_ACTIONS"])
-
- local function otherProfilesGenerator()
- local container = Settings.CreateControlTextContainer()
- local current = ns.Addon.db:GetCurrentProfile()
- for _, name in ipairs(ns.Addon.db:GetProfiles()) do
- if name ~= current then
- container:Add(name, name)
- end
- end
- return container:GetData()
- end
-
- local _, getCopyProfile, clearCopyProfile =
- createProfilePicker(SB, cat, "ProfileCopy", L["COPY_FROM"], L["COPY_FROM_DESC"], otherProfilesGenerator)
-
- SB.Button({
+ },
+ { type = "header", name = L["PROFILE_ACTIONS"] },
+ copyProfileRow,
+ {
+ type = "button",
name = L["COPY"],
buttonText = L["COPY"],
tooltip = L["COPY_DESC"],
- onClick = function()
+ onClick = function(page)
local profile = getCopyProfile()
if not profile or profile == "" then
return
@@ -187,27 +194,19 @@ function ProfileOptions.RegisterSettings(SB)
StaticPopup_Show("ECM_CONFIRM_COPY_PROFILE", profile, nil, {
onAccept = function()
ns.Addon.db:CopyProfile(profile)
- clearCopyProfile()
- refreshCategory()
+ resetCopyProfile()
+ page:Refresh()
end,
})
end,
- })
-
- local _, getDeleteProfile, clearDeleteProfile = createProfilePicker(
- SB,
- cat,
- "ProfileDelete",
- L["DELETE_PROFILE"],
- L["DELETE_PROFILE_SELECT_DESC"],
- otherProfilesGenerator
- )
-
- SB.Button({
+ },
+ deleteProfileRow,
+ {
+ type = "button",
name = L["DELETE"],
buttonText = L["DELETE"],
tooltip = L["DELETE_DESC"],
- onClick = function()
+ onClick = function(page)
local profile = getDeleteProfile()
if not profile or profile == "" then
return
@@ -215,30 +214,27 @@ function ProfileOptions.RegisterSettings(SB)
StaticPopup_Show("ECM_CONFIRM_DELETE_PROFILE", profile, nil, {
onAccept = function()
ns.Addon.db:DeleteProfile(profile)
- clearDeleteProfile()
- refreshCategory()
+ resetDeleteProfile()
+ page:Refresh()
end,
})
end,
- })
-
- -- Reset
- SB.Header(L["RESET"])
-
- SB.Button({
+ },
+ { type = "header", name = L["RESET"] },
+ {
+ type = "button",
name = L["RESET_PROFILE"],
buttonText = L["RESET_PROFILE_BUTTON"],
tooltip = L["RESET_PROFILE_DESC"],
confirm = L["RESET_PROFILE_CONFIRM"],
- onClick = function()
+ onClick = function(page)
ns.Addon.db:ResetProfile()
+ page:Refresh()
end,
- })
-
- -- Import / Export
- SB.Header(L["IMPORT_EXPORT"])
-
- SB.Button({
+ },
+ { type = "header", name = L["IMPORT_EXPORT"] },
+ {
+ type = "button",
name = L["IMPORT_PROFILE"],
buttonText = L["IMPORT"],
tooltip = L["IMPORT_DESC"],
@@ -249,9 +245,9 @@ function ProfileOptions.RegisterSettings(SB)
end
ns.Addon:ShowImportDialog()
end,
- })
-
- SB.Button({
+ },
+ {
+ type = "button",
name = L["EXPORT_PROFILE"],
buttonText = L["EXPORT"],
tooltip = L["EXPORT_DESC"],
@@ -263,7 +259,5 @@ function ProfileOptions.RegisterSettings(SB)
end
ns.Addon:ShowExportDialog(exportString)
end,
- })
-end
-
-ns.SettingsBuilder.RegisterSection(ns, "Profile", ProfileOptions)
+ },
+}
diff --git a/UI/ResourceBarOptions.lua b/UI/ResourceBarOptions.lua
index 2b455d37..d94115ba 100644
--- a/UI/ResourceBarOptions.lua
+++ b/UI/ResourceBarOptions.lua
@@ -62,61 +62,56 @@ local RESOURCE_COLOR_DEFS = {
}
local ResourceBarOptions = {}
+ns.ResourceBarOptions = ResourceBarOptions
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("resourceBar")
-function ResourceBarOptions.RegisterSettings(SB)
- local rows = {
- {
- type = "checkbox",
- path = "enabled",
- name = L["ENABLE_RESOURCE_BAR"],
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("ResourceBar"),
- },
- }
- local maxColorDefs = {}
- for _, def in ipairs(RESOURCE_COLOR_DEFS) do
- if C.RESOURCEBAR_MAX_COLOR_TYPES[def.key] then
- maxColorDefs[#maxColorDefs + 1] = {
- key = def.key,
- name = def.name,
- tooltip = L["ALTERNATE_COLOR_TOOLTIP"],
- }
- end
- end
-
- for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
- rows[#rows + 1] = row
+local rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_RESOURCE_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("ResourceBar"),
+ },
+}
+local maxColorDefs = {}
+for _, def in ipairs(RESOURCE_COLOR_DEFS) do
+ if C.RESOURCEBAR_MAX_COLOR_TYPES[def.key] then
+ maxColorDefs[#maxColorDefs + 1] = {
+ key = def.key,
+ name = def.name,
+ tooltip = L["ALTERNATE_COLOR_TOOLTIP"],
+ }
end
+end
- rows[#rows + 1] = { type = "header", name = L["COLORS"], disabled = isDisabled }
- rows[#rows + 1] = {
- type = "colorList",
- path = "colors",
- label = L["RESOURCE_TYPES"],
- defs = RESOURCE_COLOR_DEFS,
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "checkboxList",
- path = "maxColorsEnabled",
- label = L["USE_ALTERNATE_COLOR_WHEN_CAPPED"],
- defs = maxColorDefs,
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "colorList",
- path = "maxColors",
- label = L["ALTERNATE_COLORS"],
- defs = maxColorDefs,
- disabled = isDisabled,
- }
-
- SB.RegisterPage({
- name = L["RESOURCE_BAR"],
- path = "resourceBar",
- disabled = ns.IsDeathKnight,
- rows = rows,
- })
+for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled)) do
+ rows[#rows + 1] = row
end
-ns.SettingsBuilder.RegisterSection(ns, "ResourceBar", ResourceBarOptions)
+rows[#rows + 1] = { type = "header", name = L["COLORS"], disabled = isDisabled }
+rows[#rows + 1] = {
+ type = "colorList",
+ path = "colors",
+ label = L["RESOURCE_TYPES"],
+ defs = RESOURCE_COLOR_DEFS,
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "checkboxList",
+ path = "maxColorsEnabled",
+ label = L["USE_ALTERNATE_COLOR_WHEN_CAPPED"],
+ defs = maxColorDefs,
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "colorList",
+ path = "maxColors",
+ label = L["ALTERNATE_COLORS"],
+ defs = maxColorDefs,
+ disabled = isDisabled,
+}
+
+ResourceBarOptions.key = "resourceBar"
+ResourceBarOptions.name = L["RESOURCE_BAR"]
+ResourceBarOptions.disabled = ns.IsDeathKnight
+ResourceBarOptions.rows = rows
diff --git a/UI/RuneBarOptions.lua b/UI/RuneBarOptions.lua
index fe1ff68a..3bba920e 100644
--- a/UI/RuneBarOptions.lua
+++ b/UI/RuneBarOptions.lua
@@ -5,85 +5,80 @@
local _, ns = ...
local L = ns.L
local RuneBarOptions = {}
+ns.RuneBarOptions = RuneBarOptions
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
-function RuneBarOptions.RegisterSettings(SB)
- local rows = {
- {
- type = "subheader",
- name = L["DK_ONLY_WARNING"],
- condition = function()
- return not ns.IsDeathKnight()
- end,
- },
- {
- type = "checkbox",
- path = "enabled",
- name = L["ENABLE_RUNE_BAR"],
- onSet = ns.OptionUtil.CreateModuleEnabledHandler("RuneBar"),
- },
- }
-
- for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled, { showText = false, border = false })) do
- rows[#rows + 1] = row
- end
-
- rows[#rows + 1] = {
- id = "colorLabel",
+local rows = {
+ {
type = "subheader",
- name = L["COLORS"],
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- id = "useSpecColor",
- type = "checkbox",
- path = "useSpecColor",
- name = L["USE_SPEC_COLOR"],
- desc = L["USE_SPEC_COLOR_DESC"],
- parent = "colorLabel",
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "color",
- path = "color",
- name = L["RUNE_COLOR"],
- parent = "useSpecColor",
- parentCheck = "notChecked",
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "color",
- path = "colorBlood",
- name = L["BLOOD_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "color",
- path = "colorFrost",
- name = L["FROST_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
- }
- rows[#rows + 1] = {
- type = "color",
- path = "colorUnholy",
- name = L["UNHOLY_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
- }
-
- SB.RegisterPage({
- name = L["RUNE_BAR"],
- path = "runeBar",
- disabled = function()
+ name = L["DK_ONLY_WARNING"],
+ condition = function()
return not ns.IsDeathKnight()
end,
- rows = rows,
- })
+ },
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_RUNE_BAR"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("RuneBar"),
+ },
+}
+
+for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled, { showText = false, border = false })) do
+ rows[#rows + 1] = row
end
-ns.SettingsBuilder.RegisterSection(ns, "RuneBar", RuneBarOptions)
+rows[#rows + 1] = {
+ id = "colorLabel",
+ type = "subheader",
+ name = L["COLORS"],
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ id = "useSpecColor",
+ type = "checkbox",
+ path = "useSpecColor",
+ name = L["USE_SPEC_COLOR"],
+ desc = L["USE_SPEC_COLOR_DESC"],
+ parent = "colorLabel",
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "color",
+ path = "color",
+ name = L["RUNE_COLOR"],
+ parent = "useSpecColor",
+ parentCheck = "notChecked",
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "color",
+ path = "colorBlood",
+ name = L["BLOOD_COLOR"],
+ parent = "useSpecColor",
+ parentCheck = "checked",
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "color",
+ path = "colorFrost",
+ name = L["FROST_COLOR"],
+ parent = "useSpecColor",
+ parentCheck = "checked",
+ disabled = isDisabled,
+}
+rows[#rows + 1] = {
+ type = "color",
+ path = "colorUnholy",
+ name = L["UNHOLY_COLOR"],
+ parent = "useSpecColor",
+ parentCheck = "checked",
+ disabled = isDisabled,
+}
+
+RuneBarOptions.key = "runeBar"
+RuneBarOptions.name = L["RUNE_BAR"]
+RuneBarOptions.disabled = function()
+ return not ns.IsDeathKnight()
+end
+RuneBarOptions.rows = rows
From 9e17097f64dad1209ed6fb3615ff3d5e47693886 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 15 Apr 2026 17:01:30 +1000
Subject: [PATCH 15/53] phase 1
---
ARCHITECTURE.md | 7 +++++
Libs/LibSettingsBuilder/Core.lua | 6 ++++
Libs/LibSettingsBuilder/Primitives/Layout.lua | 5 +++
Libs/LibSettingsBuilder/README.md | 15 +++++++++
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 14 +++++++++
Libs/LibSettingsBuilder/Utility.lua | 9 ++++++
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 31 +++++++++++++++++++
7 files changed, 87 insertions(+)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index a0a08168..60bd5d9c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -202,6 +202,13 @@ Options pages now use LibSettingsBuilder as a single declarative registration tr
- `root:Register(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes.
+LibSettingsBuilder v2 Phase 1 also freezes the intended replacement surface without switching ECM over to it yet:
+
+- target factory: `LSB.New(config)`
+- target runtime lookups: `lsb:GetSection(...)`, `lsb:GetRootPage()`, `lsb:GetPage(...)`, `lsb:HasCategory(...)`
+- target page handle API: `page:GetId()`, `page:Refresh()`
+- deprecated compatibility namespace: `LSBDeprecated`
+
Deprecated non-declarative page-construction APIs were removed from the builder surface. ECM settings pages are registered through the root tree only.
Pages still use the same canonical row types:
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index d53a3baf..a6831837 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -15,9 +15,11 @@ end
lib._loadState = { open = true }
lib._internal = {}
lib.BuilderMixin = lib.BuilderMixin or {}
+lib.LSBDeprecated = lib.LSBDeprecated or {}
local internal = lib._internal
local BuilderMixin = lib.BuilderMixin
+local Deprecated = lib.LSBDeprecated
lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
@@ -370,6 +372,8 @@ lib.CreateHeaderTitle = createHeaderTitle
lib.CreateSubheaderTitle = createSubheaderTitle
BuilderMixin.CreateHeaderTitle = createHeaderTitle
BuilderMixin.CreateSubheaderTitle = createSubheaderTitle
+Deprecated.CreateHeaderTitle = createHeaderTitle
+Deprecated.CreateSubheaderTitle = createSubheaderTitle
--------------------------------------------------------------------------------
-- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
@@ -401,6 +405,7 @@ lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults
local CanvasLayout = {}
lib.CanvasLayout = CanvasLayout
+Deprecated.CanvasLayout = CanvasLayout
local function getCanvasLayoutMetrics(layout)
return layout._metrics or lib.CanvasLayoutDefaults
@@ -558,6 +563,7 @@ BuilderMixin.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
BuilderMixin.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
BuilderMixin.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
BuilderMixin.CreateColorSwatch = lib.CreateColorSwatch
+Deprecated.CreateColorSwatch = lib.CreateColorSwatch
--------------------------------------------------------------------------------
-- Path accessors: built-in dot-path resolution with numeric key support
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index 72af711d..e835ae4f 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -11,6 +11,7 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
local BuilderMixin = lib.BuilderMixin
+local Deprecated = lib.LSBDeprecated
function BuilderMixin:_createRootCategory(name)
local category, layout = Settings.RegisterVerticalLayoutCategory(name)
@@ -52,3 +53,7 @@ function BuilderMixin:CreateCanvasLayout(name, parentCategory)
_metrics = metrics,
}, { __index = lib.CanvasLayout })
end
+
+Deprecated.CreateCanvasLayout = function(...)
+ return BuilderMixin.CreateCanvasLayout(...)
+end
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index 7be7b5fa..a75ffe07 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -17,6 +17,21 @@ It supports:
Distributed via [LibStub](https://www.wowace.com/projects/libstub).
+## v2 status
+
+Phase 1 of the v2 rearchitecture is now frozen.
+
+That means the target public surface is defined even though the runtime still carries the compatibility APIs used by the current addon code:
+
+- target factory: `LSB.New(config)`
+- target runtime object: `lsb`
+- target lookups: `lsb:GetSection(sectionKey)`, `lsb:GetRootPage()`, `lsb:GetPage(sectionKey, pageKey)`, `lsb:HasCategory(category)`
+- target page handle: `page:GetId()`, `page:Refresh()`
+- target schema root: `config.page` plus `config.sections`
+- deprecated compatibility namespace: `LSBDeprecated`
+
+Until later migration phases land, existing `LSB:New(...)`, `SB.GetRoot(...)`, `root:Register(...)`, and helper-style APIs remain available for compatibility.
+
## At a glance
| Need | LibSettingsBuilder |
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index 1a6f310a..cb5c1a27 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -35,9 +35,23 @@ describe("LibSettingsBuilder Core", function()
assert.is_table(lsb)
assert.is_function(lsb.PathAdapter)
assert.is_function(lsb.CreateColorSwatch)
+ assert.is_table(lsb.LSBDeprecated)
assert.is_nil(lsb._loadState.open)
end)
+ it("exposes the planned deprecated namespace aliases", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local deprecated = lsb.LSBDeprecated
+
+ assert.are.equal(lsb.CreateColorSwatch, deprecated.CreateColorSwatch)
+ assert.are.equal(lsb.CreateHeaderTitle, deprecated.CreateHeaderTitle)
+ assert.are.equal(lsb.CreateSubheaderTitle, deprecated.CreateSubheaderTitle)
+ assert.is_function(deprecated.CreateCanvasLayout)
+ assert.is_function(deprecated.SetCanvasLayoutDefaults)
+ assert.is_function(deprecated.ConfigureCanvasLayout)
+ assert.are.equal(lsb.CanvasLayout, deprecated.CanvasLayout)
+ end)
+
it("PathAdapter resolves nested values and defaults", function()
local profile = {
root = {
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 98204f94..4e608343 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -13,6 +13,7 @@ local copyMixin = internal.copyMixin
local installPageLifecycleHooks = internal.installPageLifecycleHooks
local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
local BuilderMixin = lib.BuilderMixin
+local Deprecated = lib.LSBDeprecated
local SectionMethods = {}
local PageMethods = {}
@@ -96,6 +97,14 @@ function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
return layout._metrics
end
+Deprecated.SetCanvasLayoutDefaults = function(...)
+ return BuilderMixin.SetCanvasLayoutDefaults(...)
+end
+
+Deprecated.ConfigureCanvasLayout = function(...)
+ return BuilderMixin.ConfigureCanvasLayout(...)
+end
+
function BuilderMixin:Control(spec)
local methodName = DISPATCH[spec.type]
assert(methodName, "Control: unknown type '" .. tostring(spec.type) .. "'")
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index 94c7f2e4..5ccf5567 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -8,6 +8,37 @@
- [Migration Guide](MIGRATION_GUIDE.md)
- [Troubleshooting](TROUBLESHOOTING.md)
+## v2 Freeze
+
+Phase 1 freezes the intended v2 public surface, but does not yet remove the compatibility APIs that the current addon still uses.
+
+Target v2 surface:
+
+- `LSB.New(config)`
+- `lsb:GetSection(sectionKey)`
+- `lsb:GetRootPage()`
+- `lsb:GetPage(sectionKey, pageKey)`
+- `lsb:HasCategory(category)`
+- `page:GetId()`
+- `page:Refresh()`
+- deprecated compatibility namespace: `LSBDeprecated`
+
+Current compatibility APIs documented below remain live until the later migration phases replace them.
+
+### `LSBDeprecated`
+
+Phase 1 establishes `LSBDeprecated` as the compatibility namespace for APIs that will move off the main `LSB` table in a later phase.
+
+Currently exposed there:
+
+- `LSBDeprecated.CreateCanvasLayout(...)`
+- `LSBDeprecated.SetCanvasLayoutDefaults(...)`
+- `LSBDeprecated.ConfigureCanvasLayout(...)`
+- `LSBDeprecated.CreateColorSwatch(...)`
+- `LSBDeprecated.CreateHeaderTitle(...)`
+- `LSBDeprecated.CreateSubheaderTitle(...)`
+- `LSBDeprecated.CanvasLayout`
+
## Factory
### `LSB:New(config)`
From c3b3aeae64f50188a7885433abe67c1d89237900 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 15 Apr 2026 21:48:24 +1000
Subject: [PATCH 16/53] phase 2 complete.
---
ARCHITECTURE.md | 2 +
.../CompositeControls/Groups.lua | 32 +-
.../CompositeControls/Lists.lua | 4 +-
Libs/LibSettingsBuilder/Controls/Base.lua | 19 +-
.../Controls/CollectionFrames.lua | 4 +-
.../Controls/Collections.lua | 4 +-
Libs/LibSettingsBuilder/Controls/Rows.lua | 23 +-
Libs/LibSettingsBuilder/Core.lua | 185 +-
Libs/LibSettingsBuilder/Primitives/Layout.lua | 6 +-
Libs/LibSettingsBuilder/README.md | 72 +-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 3655 +----------------
.../Tests/Collections_spec.lua | 74 +-
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 144 +-
Libs/LibSettingsBuilder/Utility.lua | 591 +--
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 108 +-
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 8 +-
.../docs/MIGRATION_GUIDE.md | 22 +-
Libs/LibSettingsBuilder/docs/QUICK_START.md | 146 +-
.../docs/TROUBLESHOOTING.md | 21 +-
Tests/TestHelpers.lua | 11 +-
Tests/UI/About_spec.lua | 22 +-
Tests/UI/BuffBarsOptions_spec.lua | 4 +-
Tests/UI/ExtraIconsOptions_spec.lua | 6 +-
Tests/UI/LayoutOptions_spec.lua | 2 +-
Tests/UI/OptionsSections_spec.lua | 21 +-
Tests/UI/Options_spec.lua | 27 +-
Tests/UI/PowerBarOptions_spec.lua | 4 +-
Tests/UI/PowerBarTickMarksOptions_spec.lua | 6 +-
Tests/UI/ProfileOptions_spec.lua | 2 +-
Tests/UI/ResourceBarOptions_spec.lua | 2 +-
Tests/UI/RuneBarOptions_spec.lua | 8 +-
UI/BuffBarsOptions.lua | 26 +-
UI/ExtraIconsOptions.lua | 35 +-
UI/ExtraIconsOptionsUtil.lua | 11 +-
UI/GeneralOptions.lua | 50 +-
UI/LayoutOptions.lua | 27 +-
UI/OptionUtil.lua | 49 +-
UI/Options.lua | 61 +-
UI/PowerBarOptions.lua | 2 +-
UI/PowerBarTickMarksOptions.lua | 18 +-
UI/ProfileOptions.lua | 27 +-
UI/ResourceBarOptions.lua | 7 +-
UI/RuneBarOptions.lua | 49 +-
43 files changed, 1143 insertions(+), 4454 deletions(-)
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 60bd5d9c..b913e168 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -209,6 +209,8 @@ LibSettingsBuilder v2 Phase 1 also freezes the intended replacement surface with
- target page handle API: `page:GetId()`, `page:Refresh()`
- deprecated compatibility namespace: `LSBDeprecated`
+Phase 2 then makes raw declarative row tables the canonical registration schema and removes builder-level row helper constructors from the public `lsb` surface. ECM continues to register through plain row tables.
+
Deprecated non-declarative page-construction APIs were removed from the builder surface. ECM settings pages are registered through the root tree only.
Pages still use the same canonical row types:
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
index d2881a69..bab7de3e 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
@@ -8,7 +8,11 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = lib._internal.BuilderMixin
+
+local function callBuilder(builder, methodName, ...)
+ return BuilderMixin[methodName](builder, ...)
+end
function BuilderMixin:HeightOverrideSlider(sectionPath, spec)
spec = spec or {}
@@ -27,7 +31,7 @@ function BuilderMixin:HeightOverrideSlider(sectionPath, spec)
end,
}
self:_propagateModifiers(childSpec, spec)
- return self:Slider(childSpec)
+ return callBuilder(self, "Slider", childSpec)
end
--- Font override group.
@@ -37,7 +41,7 @@ end
--- fontSizeFallback function() -> number (fallback font size)
--- fontTemplate string (custom template for the font picker)
function BuilderMixin:FontOverrideGroup(sectionPath, spec)
- spec = self:_mergeCompositeDefaults("FontOverrideGroup", spec)
+ spec = spec or {}
local overridePath = sectionPath .. ".overrideFont"
local enabledSpec = {
@@ -49,7 +53,7 @@ function BuilderMixin:FontOverrideGroup(sectionPath, spec)
end,
}
self:_propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = self:Checkbox(enabledSpec)
+ local enabledInit, enabledSetting = callBuilder(self, "Checkbox", enabledSpec)
local outerDisabled = spec.disabled
local function isOverrideDisabled()
@@ -80,9 +84,9 @@ function BuilderMixin:FontOverrideGroup(sectionPath, spec)
local fontInit
if spec.fontTemplate then
fontSpec.template = spec.fontTemplate
- fontInit = self:Custom(fontSpec)
+ fontInit = callBuilder(self, "Custom", fontSpec)
else
- fontInit = self:Dropdown(fontSpec)
+ fontInit = callBuilder(self, "Dropdown", fontSpec)
end
local sizeSpec = {
@@ -104,7 +108,7 @@ function BuilderMixin:FontOverrideGroup(sectionPath, spec)
end,
}
self:_propagateModifiers(sizeSpec, spec)
- local sizeInit = self:Slider(sizeSpec)
+ local sizeInit = callBuilder(self, "Slider", sizeSpec)
return {
enabledInit = enabledInit,
@@ -123,7 +127,7 @@ function BuilderMixin:BorderGroup(borderPath, spec)
tooltip = spec.enabledTooltip,
}
self:_propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = self:Checkbox(enabledSpec)
+ local enabledInit, enabledSetting = callBuilder(self, "Checkbox", enabledSpec)
local thicknessSpec = {
path = borderPath .. ".thickness",
@@ -132,25 +136,25 @@ function BuilderMixin:BorderGroup(borderPath, spec)
min = spec.thicknessMin or 1,
max = spec.thicknessMax or 10,
step = spec.thicknessStep or 1,
- parent = enabledInit,
- parentCheck = function()
+ _parentInitializer = enabledInit,
+ _parentPredicate = function()
return enabledSetting:GetValue()
end,
}
self:_propagateModifiers(thicknessSpec, spec)
- local thicknessInit = self:Slider(thicknessSpec)
+ local thicknessInit = callBuilder(self, "Slider", thicknessSpec)
local colorSpec = {
path = borderPath .. ".color",
name = spec.colorName or "Border color",
tooltip = spec.colorTooltip,
- parent = enabledInit,
- parentCheck = function()
+ _parentInitializer = enabledInit,
+ _parentPredicate = function()
return enabledSetting:GetValue()
end,
}
self:_propagateModifiers(colorSpec, spec)
- local colorInit = self:Color(colorSpec)
+ local colorInit = callBuilder(self, "Color", colorSpec)
return {
enabledInit = enabledInit,
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
index 126981e8..212b553b 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
@@ -8,7 +8,7 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = lib._internal.BuilderMixin
local function buildControlList(builder, basePath, defs, spec, methodName)
local results = {}
@@ -20,7 +20,7 @@ local function buildControlList(builder, basePath, defs, spec, methodName)
tooltip = def.tooltip,
}
builder:_propagateModifiers(childSpec, spec)
- local initializer, setting = builder[methodName](builder, childSpec)
+ local initializer, setting = BuilderMixin[methodName](builder, childSpec)
results[#results + 1] = { key = def.key, initializer = initializer, setting = setting }
end
return results
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index 446e0835..c0142975 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -15,7 +15,7 @@ local getSettingVariable = internal.getSettingVariable
local applyInputRowEnabledState = internal.applyInputRowEnabledState
local applyInputRowFrame = internal.applyInputRowFrame
local cancelInputPreviewTimer = internal.cancelInputPreviewTimer
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = internal.BuilderMixin
function BuilderMixin:Checkbox(spec)
self:_validateSpecFields("checkbox", spec)
@@ -165,23 +165,8 @@ function BuilderMixin:Input(spec)
width = spec.width,
}
- local watchVariables = {}
- if spec.watch then
- for _, identifier in ipairs(spec.watch) do
- watchVariables[#watchVariables + 1] = self:_makeVarNameFromIdentifier(identifier)
- end
- end
- if spec.watchVariables then
- for _, variable in ipairs(spec.watchVariables) do
- watchVariables[#watchVariables + 1] = variable
- end
- end
- if #watchVariables > 0 then
- data.watchVariables = watchVariables
- end
-
local extent = spec.resolveText and 46 or 26
- local initializer = createCustomListRowInitializer(lib.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
+ local initializer = createCustomListRowInitializer(internal.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
local originalInitFrame = initializer.InitFrame
local originalResetter = initializer.Resetter
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index e59c4385..778ffef7 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -132,7 +132,7 @@ local function ensureSwatchCollectionRow(row)
row._label:SetJustifyH("LEFT")
row._label:SetWordWrap(false)
- row._swatch = lib.CreateColorSwatch(row)
+ row._swatch = internal.createColorSwatch(row)
row._swatch:SetPoint("LEFT", row, "CENTER", DEFAULT_SWATCH_CENTER_X, 0)
end
@@ -189,7 +189,7 @@ local function ensureEditorCollectionRow(row)
row._label:SetJustifyH("LEFT")
row._fieldWidgets = {}
- row._swatch = lib.CreateColorSwatch(row)
+ row._swatch = internal.createColorSwatch(row)
row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
row._removeButton:SetSize(70, 22)
end
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index 2b20979c..79a76027 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -12,7 +12,7 @@ local internal = lib._internal
local applyCollectionFrame = internal.applyCollectionFrame
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local copyMixin = internal.copyMixin
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = internal.BuilderMixin
function BuilderMixin:_createCollectionInitializer(spec, errorPrefix)
assert(spec.height, errorPrefix .. ": spec.height is required")
@@ -23,7 +23,7 @@ function BuilderMixin:_createCollectionInitializer(spec, errorPrefix)
data.preset = data.variant
end
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
+ local initializer = createCustomListRowInitializer(internal.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
initializer._lsbEnabled = true
initializer.SetEnabled = function(controlInitializer, enabled)
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 16ec310a..120cf2f8 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -16,7 +16,7 @@ local applySubheaderFrame = internal.applySubheaderFrame
local copyMixin = internal.copyMixin
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local hideHeaderActionButtons = internal.hideHeaderActionButtons
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = internal.BuilderMixin
function BuilderMixin:_addLayoutInitializer(spec, initializer, refreshable)
local category = self:_resolveCategory(spec)
@@ -46,7 +46,7 @@ function BuilderMixin:PageActions(spec)
local categoryName = self._subcategoryNames[category]
or (category == self._rootCategory and self._rootCategoryName)
or ""
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
_lsbKind = "pageActions",
name = spec.name or categoryName,
actions = spec.actions,
@@ -61,7 +61,7 @@ function BuilderMixin:PageActions(spec)
end
function BuilderMixin:Subheader(spec)
- local initializer = createCustomListRowInitializer(lib.SUBHEADER_TEMPLATE, {
+ local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
_lsbKind = "subheader",
name = spec.name,
}, 28, applySubheaderFrame)
@@ -69,7 +69,7 @@ function BuilderMixin:Subheader(spec)
end
function BuilderMixin:InfoRow(spec)
- local initializer = createCustomListRowInitializer(lib.INFOROW_TEMPLATE, {
+ local initializer = createCustomListRowInitializer(internal.INFOROW_TEMPLATE, {
_lsbKind = "infoRow",
name = spec.name,
value = spec.value,
@@ -88,7 +88,7 @@ function BuilderMixin:EmbedCanvas(canvas, height, spec)
local modifiers = copyMixin({}, spec)
modifiers.canvas = canvas
- local initializer = createCustomListRowInitializer(lib.EMBED_CANVAS_TEMPLATE, {
+ local initializer = createCustomListRowInitializer(internal.EMBED_CANVAS_TEMPLATE, {
_lsbKind = "embedCanvas",
canvas = canvas,
}, height or canvas:GetHeight(), applyEmbedCanvasFrame)
@@ -125,16 +125,23 @@ function BuilderMixin:_ensureConfirmDialog()
end
function BuilderMixin:Button(spec)
+ local callbackContext = self:_createCallbackContext(spec)
local onClick = spec.onClick
if spec.confirm then
local confirmDialogName = self:_ensureConfirmDialog()
local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
local originalClick = onClick
- onClick = function()
- StaticPopup_Show(confirmDialogName, confirmText, nil, { onAccept = originalClick })
+ onClick = function(ctx)
+ StaticPopup_Show(confirmDialogName, confirmText, nil, {
+ onAccept = function()
+ originalClick(ctx)
+ end,
+ })
end
end
- local initializer = CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, onClick, spec.tooltip, true)
+ local initializer = CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, function()
+ onClick(callbackContext)
+ end, spec.tooltip, true)
return self:_addLayoutInitializer(spec, initializer)
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index a6831837..70d2bf5b 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -13,19 +13,19 @@ if not lib then
end
lib._loadState = { open = true }
-lib._internal = {}
-lib.BuilderMixin = lib.BuilderMixin or {}
+lib._internal = lib._internal or {}
lib.LSBDeprecated = lib.LSBDeprecated or {}
local internal = lib._internal
-local BuilderMixin = lib.BuilderMixin
+internal.BuilderMixin = internal.BuilderMixin or {}
+local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
-lib.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
-lib.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
-lib.INFOROW_TEMPLATE = "SettingsListElementTemplate"
-lib.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
-lib.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
+internal.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
+internal.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
+internal.INFOROW_TEMPLATE = "SettingsListElementTemplate"
+internal.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
+internal.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
@@ -368,10 +368,6 @@ local function createHeaderTitle(parent, text)
return createTitle(parent, "GameFontHighlightLarge", 7, -16, text)
end
-lib.CreateHeaderTitle = createHeaderTitle
-lib.CreateSubheaderTitle = createSubheaderTitle
-BuilderMixin.CreateHeaderTitle = createHeaderTitle
-BuilderMixin.CreateSubheaderTitle = createSubheaderTitle
Deprecated.CreateHeaderTitle = createHeaderTitle
Deprecated.CreateSubheaderTitle = createSubheaderTitle
@@ -390,7 +386,7 @@ Deprecated.CreateSubheaderTitle = createSubheaderTitle
-- Indent per level: 15
--------------------------------------------------------------------------------
-lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults
+internal.CanvasLayoutDefaults = internal.CanvasLayoutDefaults
or {
elementHeight = 26,
headerHeight = 50,
@@ -402,13 +398,14 @@ lib.CanvasLayoutDefaults = lib.CanvasLayoutDefaults
swatchCenterX = DEFAULT_SWATCH_CENTER_X,
verifiedPatch = "Retail 12.0/12.1",
}
+Deprecated.CanvasLayoutDefaults = internal.CanvasLayoutDefaults
local CanvasLayout = {}
-lib.CanvasLayout = CanvasLayout
+internal.CanvasLayout = CanvasLayout
Deprecated.CanvasLayout = CanvasLayout
local function getCanvasLayoutMetrics(layout)
- return layout._metrics or lib.CanvasLayoutDefaults
+ return layout._metrics or internal.CanvasLayoutDefaults
end
function CanvasLayout:_Advance(h)
@@ -479,7 +476,7 @@ function CanvasLayout:AddColorSwatch(labelText)
local metrics = getCanvasLayoutMetrics(self)
local row = self:_CreateRow()
self:_AddLabel(row, labelText)
- local swatch = lib.CreateColorSwatch(row)
+ local swatch = internal.createColorSwatch(row)
swatch:SetPoint("LEFT", row, "CENTER", metrics.swatchCenterX, 0)
row._swatch = swatch
return row, swatch
@@ -545,7 +542,7 @@ end
--- SettingsColorSwatchMixin (hover effects, color picker integration).
---@param parent Frame
---@return Button swatch (swatch._tex points to swatch.Color for backward compat)
-function lib.CreateColorSwatch(parent)
+local function createColorSwatch(parent)
local swatch = CreateFrame("Button", nil, parent, "SettingsColorSwatchTemplate")
swatch._tex = swatch.Color
if swatch.EnableMouse then
@@ -556,14 +553,9 @@ function lib.CreateColorSwatch(parent)
end
return swatch
end
+internal.createColorSwatch = createColorSwatch
-BuilderMixin.EMBED_CANVAS_TEMPLATE = lib.EMBED_CANVAS_TEMPLATE
-BuilderMixin.SUBHEADER_TEMPLATE = lib.SUBHEADER_TEMPLATE
-BuilderMixin.INFOROW_TEMPLATE = lib.INFOROW_TEMPLATE
-BuilderMixin.INPUTROW_TEMPLATE = lib.INPUTROW_TEMPLATE
-BuilderMixin.SCROLL_DROPDOWN_TEMPLATE = lib.SCROLL_DROPDOWN_TEMPLATE
-BuilderMixin.CreateColorSwatch = lib.CreateColorSwatch
-Deprecated.CreateColorSwatch = lib.CreateColorSwatch
+Deprecated.CreateColorSwatch = createColorSwatch
--------------------------------------------------------------------------------
-- Path accessors: built-in dot-path resolution with numeric key support
@@ -619,16 +611,7 @@ local function defaultSetNestedValue(tbl, path, value)
current[resolved] = value
end
---- Creates a path adapter for resolving dot-delimited paths to get/set/default
---- bindings. Built-in accessors handle numeric path segments (e.g. "colors.0").
----@param config table
---- Required: getStore (function() -> table), getDefaults (function() -> table)
---- Optional: getNestedValue, setNestedValue (custom path accessors)
----@return table adapter with :resolve(path) and :read(path) methods
-function lib.PathAdapter(config)
- assert(config.getStore, "PathAdapter: getStore is required")
- assert(config.getDefaults, "PathAdapter: getDefaults is required")
-
+local function createStoreAdapter(config)
local getNested = config.getNestedValue or defaultGetNestedValue
local setNested = config.setNestedValue or defaultSetNestedValue
@@ -654,7 +637,29 @@ local function defaultSliderFormatter(value)
return value == math.floor(value) and tostring(math.floor(value)) or string.format("%.1f", value)
end
-local MODIFIER_KEYS = { "category", "parent", "parentCheck", "disabled", "hidden", "layout" }
+local function makeVarPrefixFromName(name)
+ local words = {}
+ for word in tostring(name or ""):gmatch("[A-Za-z0-9]+") do
+ words[#words + 1] = word
+ end
+
+ local prefix = ""
+ if #words > 1 then
+ for _, word in ipairs(words) do
+ prefix = prefix .. word:sub(1, 1):upper()
+ end
+ elseif words[1] then
+ prefix = words[1]:upper():gsub("[^A-Z0-9]", "")
+ end
+
+ if prefix == "" then
+ prefix = "LSB"
+ end
+
+ return prefix
+end
+
+local MODIFIER_KEYS = { "category", "disabled", "hidden", "layout" }
local COMMON_SPEC_FIELDS = {
path = true,
@@ -664,8 +669,6 @@ local COMMON_SPEC_FIELDS = {
onSet = true,
getTransform = true,
setTransform = true,
- parent = true,
- parentCheck = true,
disabled = true,
hidden = true,
layout = true,
@@ -688,8 +691,6 @@ local EXTRA_FIELDS_BY_TYPE = {
numeric = true,
onTextChanged = true,
resolveText = true,
- watch = true,
- watchVariables = true,
width = true,
},
custom = { template = true, varType = true },
@@ -704,6 +705,18 @@ function BuilderMixin:_makeVarName(spec)
return self:_makeVarNameFromIdentifier(id)
end
+function BuilderMixin:_createCallbackContext(spec, setting)
+ return {
+ builder = self,
+ category = self:_resolveCategory(spec),
+ key = spec.key,
+ page = spec._page,
+ path = spec.path,
+ setting = setting,
+ spec = spec,
+ }
+end
+
function BuilderMixin:_resolveCategory(spec)
return spec.category or self._currentSubcategory or self._rootCategory
end
@@ -729,10 +742,11 @@ function BuilderMixin:_registerCategoryRefreshable(category, initializer)
end
function BuilderMixin:_postSet(spec, value, setting)
+ local ctx = self:_createCallbackContext(spec, setting)
if spec.onSet then
- spec.onSet(value, setting, spec._page)
+ spec.onSet(ctx, value)
end
- self._config.onChanged(spec, value)
+ self._config.onChanged(ctx, value)
self:_reevaluateReactiveControls()
end
@@ -750,7 +764,7 @@ function BuilderMixin:_resolveBinding(spec)
end
assert(hasPath, "spec must have either path or get/set")
- assert(self._adapter, "path mode requires a pathAdapter on the builder")
+ assert(self._adapter, "path mode requires store/defaults on the builder")
local binding = self._adapter:resolve(spec.path)
if spec.default ~= nil then
@@ -789,7 +803,7 @@ function BuilderMixin:_makeProxySetting(spec, varType, defaultFallback, binding)
local function setValueNoCallback(_, value)
value = applyValue(value)
- self._config.onChanged(spec, value)
+ self._config.onChanged(self:_createCallbackContext(spec, setting), value)
self:_reevaluateReactiveControls()
end
@@ -816,14 +830,6 @@ function BuilderMixin:_propagateModifiers(target, source)
end
end
-function BuilderMixin:_mergeCompositeDefaults(functionName, spec)
- local defaults = self._config.compositeDefaults and self._config.compositeDefaults[functionName]
- if not defaults then
- return spec or {}
- end
- return spec and copyMixin(copyMixin({}, defaults), spec) or copyMixin({}, defaults)
-end
-
function BuilderMixin:_validateSpecFields(controlType, spec)
if not LSB_DEBUG then
return
@@ -865,17 +871,17 @@ function BuilderMixin:_setCanvasInteractive(frame, enabled)
end
function BuilderMixin:_isParentEnabled(spec)
- if not spec.parent then
+ if not spec._parentInitializer then
return true
end
- if spec.parentCheck then
- return spec.parentCheck()
+ if spec._parentPredicate then
+ return spec._parentPredicate()
end
- if not spec.parent.GetSetting then
+ if not spec._parentInitializer.GetSetting then
return true
end
- local setting = spec.parent:GetSetting()
+ local setting = spec._parentInitializer:GetSetting()
if not setting then
return true
end
@@ -934,15 +940,15 @@ function BuilderMixin:_applyModifiers(initializer, spec)
return
end
- if spec.disabled or spec.canvas or spec.parent then
+ if spec.disabled or spec.canvas or spec._parentInitializer then
initializer:AddModifyPredicate(function()
return self:_applyEnabledState(initializer, spec)
end)
self:_applyEnabledState(initializer, spec)
end
- if spec.parent then
- initializer:SetParentInitializer(spec.parent, function()
+ if spec._parentInitializer then
+ initializer:SetParentInitializer(spec._parentInitializer, function()
return self:_isParentEnabled(spec)
end)
end
@@ -980,24 +986,45 @@ end
BuilderMixin._defaultSliderFormatter = defaultSliderFormatter
---- Create a new SettingsBuilder instance.
+--- Create a new LibSettingsBuilder runtime instance.
---@param config table
--- Required fields:
---- varPrefix string e.g. "ECM"
---- onChanged function(spec, value) called after each setter
+--- onChanged function(ctx, value) called after each setter
--- Optional fields:
--- name string root category display name for declarative registration
---- pathAdapter table PathAdapter instance for path-based controls
---- compositeDefaults table keyed by composite function name
+--- store table nested config store for path-bound rows
+--- defaults table nested defaults table for path-bound rows
---@return table builder instance with the full SB API
-function lib:New(config)
- assert(config.varPrefix, "LibSettingsBuilder: varPrefix is required")
+function lib.New(selfOrConfig, maybeConfig)
+ local config = maybeConfig or selfOrConfig
+ assert(type(config) == "table", "LibSettingsBuilder.New: config table is required")
+
+ assert(config.varPrefix == nil, "LibSettingsBuilder: varPrefix is not part of the v2 config")
+ assert(config.pathAdapter == nil, "LibSettingsBuilder: pathAdapter is not part of the v2 config")
+ assert(config.compositeDefaults == nil, "LibSettingsBuilder: compositeDefaults is not part of the v2 config")
+ config.varPrefix = makeVarPrefixFromName(config.name)
assert(config.onChanged, "LibSettingsBuilder: onChanged is required")
+ local adapter
+ if config.store ~= nil then
+ local getStore = type(config.store) == "function" and config.store or function()
+ return config.store
+ end
+ local getDefaults = type(config.defaults) == "function" and config.defaults or function()
+ return config.defaults
+ end
+ adapter = createStoreAdapter({
+ getDefaults = getDefaults,
+ getNestedValue = config.getNestedValue,
+ getStore = getStore,
+ setNestedValue = config.setNestedValue,
+ })
+ end
+
local SB
SB = setmetatable({
_config = config,
- _adapter = config.pathAdapter,
+ _adapter = adapter,
_boundMethods = {},
_rootCategory = nil,
_rootCategoryName = nil,
@@ -1023,6 +1050,11 @@ function lib:New(config)
return value
end
+ local publicBuilderMethods = internal.publicBuilderMethods
+ if publicBuilderMethods and key:sub(1, 1) ~= "_" and not publicBuilderMethods[key] then
+ return nil
+ end
+
local bound = SB._boundMethods[key]
if bound then
return bound
@@ -1043,6 +1075,13 @@ function lib:New(config)
SB:_initializeRoot(config.name)
end
+ if config.page or config.sections then
+ SB:_registerTree({
+ page = config.page,
+ sections = config.sections,
+ })
+ end
+
return SB
end
@@ -1061,3 +1100,15 @@ internal.applyActionButtonTextures = applyActionButtonTextures
internal.evaluateStaticOrFunction = evaluateStaticOrFunction
internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
+
+lib.BuilderMixin = nil
+lib.CanvasLayout = nil
+lib.CanvasLayoutDefaults = nil
+lib.CreateColorSwatch = nil
+lib.CreateHeaderTitle = nil
+lib.CreateSubheaderTitle = nil
+lib.EMBED_CANVAS_TEMPLATE = nil
+lib.INFOROW_TEMPLATE = nil
+lib.INPUTROW_TEMPLATE = nil
+lib.SCROLL_DROPDOWN_TEMPLATE = nil
+lib.SUBHEADER_TEMPLATE = nil
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index e835ae4f..8d7ee1bf 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -10,7 +10,7 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
function BuilderMixin:_createRootCategory(name)
@@ -45,13 +45,13 @@ end
function BuilderMixin:CreateCanvasLayout(name, parentCategory)
local frame = CreateFrame("Frame", nil)
self:_createCanvasSubcategory(frame, name, parentCategory)
- local metrics = copyMixin({}, lib.CanvasLayoutDefaults)
+ local metrics = copyMixin({}, internal.CanvasLayoutDefaults)
return setmetatable({
frame = frame,
yPos = 0,
elements = {},
_metrics = metrics,
- }, { __index = lib.CanvasLayout })
+ }, { __index = internal.CanvasLayout })
end
Deprecated.CreateCanvasLayout = function(...)
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index a75ffe07..14c5dd65 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -19,7 +19,7 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
## v2 status
-Phase 1 of the v2 rearchitecture is now frozen.
+Phases 1 and 2 of the v2 rearchitecture are now in place.
That means the target public surface is defined even though the runtime still carries the compatibility APIs used by the current addon code:
@@ -28,23 +28,23 @@ That means the target public surface is defined even though the runtime still ca
- target lookups: `lsb:GetSection(sectionKey)`, `lsb:GetRootPage()`, `lsb:GetPage(sectionKey, pageKey)`, `lsb:HasCategory(category)`
- target page handle: `page:GetId()`, `page:Refresh()`
- target schema root: `config.page` plus `config.sections`
+- raw row tables are the canonical schema at registration boundaries
+- builder-level row helper constructors are no longer public on `lsb` instances
- deprecated compatibility namespace: `LSBDeprecated`
-Until later migration phases land, existing `LSB:New(...)`, `SB.GetRoot(...)`, `root:Register(...)`, and helper-style APIs remain available for compatibility.
-
## At a glance
| Need | LibSettingsBuilder |
|---|---|
-| Standard settings pages | `SB.GetRoot(name)` → `root:Register({ page = ..., sections = { ... } })` |
+| Standard settings pages | `LSB.New({ name = ..., page = ..., sections = ... })` |
| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec |
-| Dynamic refresh | `onRegistered(page)` + `page:Refresh()` |
-| Existing AceDB profiles | `PathAdapter(...)` |
+| Dynamic refresh | lookup the registered page with `lsb:GetRootPage()` / `lsb:GetPage(...)`, then call `page:Refresh()` |
+| Existing AceDB profiles | `store = db.profile`, `defaults = defaults.profile` |
| Custom storage | handler mode with `get` / `set` / `key` |
-| Text entry / numeric ID fields | `SB.Input(...)` or `type = "input"` |
+| Text entry / numeric ID fields | `type = "input"` |
| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
| Reusable settings groups | border, font override, positioning composites |
-| XML-backed bespoke widgets | `SB.Custom(...)` |
+| XML-backed bespoke widgets | `type = "custom"` |
| Force visible rows to refresh | `page:Refresh()` |
## Quick start
@@ -52,24 +52,13 @@ Until later migration phases land, existing `LSB:New(...)`, `SB.GetRoot(...)`, `
```lua
local LSB = LibStub("LibSettingsBuilder-1.0")
-local SB = LSB:New({
- pathAdapter = LSB.PathAdapter({
- getStore = function()
- return MyAddonDB.profile
- end,
- getDefaults = function()
- return MyAddonDefaults.profile
- end,
- }),
- varPrefix = "MYADDON",
- onChanged = function()
+local lsb = LSB.New({
+ name = "My Addon",
+ store = MyAddonDB.profile,
+ defaults = MyAddonDefaults.profile,
+ onChanged = function(ctx)
MyAddon:Refresh()
end,
-})
-
-local root = SB.GetRoot("My Addon")
-
-root:Register({
page = {
key = "about",
rows = {
@@ -85,19 +74,24 @@ root:Register({
key = "general",
name = "General",
path = "general",
- rows = {
- {
- type = "checkbox",
- path = "enabled",
- name = "Enable",
- },
+ pages = {
{
- type = "slider",
- path = "opacity",
- name = "Opacity",
- min = 0,
- max = 100,
- step = 1,
+ key = "main",
+ rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = "Enable",
+ },
+ {
+ type = "slider",
+ path = "opacity",
+ name = "Opacity",
+ min = 0,
+ max = 100,
+ step = 1,
+ },
+ },
},
},
},
@@ -142,8 +136,6 @@ Supported `input` spec fields include the standard binding/modifier fields plus:
- `width` — overrides the edit box width (default `140`).
- `debounce` — delays preview refresh by N seconds.
- `resolveText(value, setting, frame)` — returns the preview text shown under the edit box.
-- `watch = { ... }` — names/paths of sibling settings that should force the preview to refresh.
-- `watchVariables = { ... }` — direct proxy-setting variable names to watch.
- `onTextChanged(text, setting, frame)` — optional hook fired after the new text is written.
Example:
@@ -177,7 +169,7 @@ The library has three main implementation paths:
- **Layout rows** — `header`, `subheader`, `info`, `button`, `canvas`, and `pageActions` are initializer/layout helpers rather than persisted settings.
- **Composite rows** — `border`, `fontOverride`, `heightOverride`, `colorList`, and `checkboxList` expand into multiple child controls.
-The recommended author-facing registration model is declarative: get the singleton root once, export plain page/section spec tables, and call `root:Register(...)` with the assembled tree. Deprecated non-declarative page-construction APIs have been removed.
+The recommended author-facing registration model is declarative: export plain page/section spec tables and pass the assembled tree to `LSB.New({ ... })`. Deprecated non-declarative page-construction APIs have been removed.
Use `pageActions` for right-aligned page buttons. Use `list` and `sectionList` for dynamic editors, and keep `canvas` / `custom` as escape hatches for truly bespoke frames. Canvas rows stay on the existing lifecycle path, so page switches continue to reuse the same proven frame handling.
@@ -216,7 +208,7 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr
- Embed the library inside your addon's `Libs/` folder.
- Load `LibStub` before `LibSettingsBuilder`.
- Load `Libs\LibSettingsBuilder\embed.xml` rather than the individual library Lua files.
-- Prefer a single `root:Register({ page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls.
+- Prefer a single `LSB.New({ page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls.
- `page:Refresh()` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes.
- Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks.
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index e76a4d2f..91f66375 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -5,3557 +5,234 @@
local TestHelpers =
assert(loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"), "Unable to load Tests/TestHelpers.lua")()
-describe("LibSettingsBuilder", function()
+describe("LibSettingsBuilder Builder", function()
local originalGlobals
- local addonNS
- local layoutUpdateCalls
- local pendingTimers
- local SB
- local function runPendingTimers()
- while #pendingTimers > 0 do
- local timer = table.remove(pendingTimers, 1)
- if not timer.cancelled and timer.callback then
- timer.callback()
- end
- end
- end
-
- local function createSB2(varPrefix, categoryName)
- local LSB2 = LibStub("LibSettingsBuilder-1.0")
- local SB2 = LSB2:New({
- pathAdapter = LSB2.PathAdapter({
- getStore = function()
- return addonNS.Addon.db.profile
- end,
- getDefaults = function()
- return addonNS.Addon.db.defaults.profile
- end,
- getNestedValue = addonNS.OptionUtil.GetNestedValue,
- setNestedValue = addonNS.OptionUtil.SetNestedValue,
- }),
- varPrefix = varPrefix,
- onChanged = function() end,
- })
- SB2.GetRoot(categoryName or "Test")
- return SB2
- end
-
- local function setCurrentCategoryFromSection(sb, sectionSpec, rootName)
- local root, _, page = TestHelpers.RegisterSectionSpec(sb, sectionSpec, rootName)
- sb._currentSubcategory = page and page._category or nil
- return root, page
- end
-
- local function createSettingsPanelMock()
- local frames = {}
- local hookScripts = {}
- local currentCategory = nil
- _G.SettingsPanel = {
- IsShown = function()
- return true
- end,
- GetSettingsList = function()
- return {
- ScrollBox = {
- ForEachFrame = function(_, fn)
- for _, f in ipairs(frames) do
- fn(f)
- end
- end,
- },
- }
- end,
- SelectCategory = function() end,
- DisplayCategory = function(self, cat)
- currentCategory = cat or currentCategory
- end,
- GetCurrentCategory = function()
- return currentCategory
- end,
- SetCurrentCategory = function(_, cat)
- currentCategory = cat
- end,
- HookScript = function(_, event, fn)
- hookScripts[event] = hookScripts[event] or {}
- hookScripts[event][#hookScripts[event] + 1] = fn
- end,
- _fireScript = function(event)
- for _, fn in ipairs(hookScripts[event] or {}) do
- fn(_G.SettingsPanel)
- end
- end,
- }
- return frames
- end
-
- local function createScriptableFrame()
- local frame = TestHelpers.makeFrame()
- frame._scripts = {}
- frame._hooks = {}
- frame._text = ""
- frame._focused = false
- frame.RegisterEvent = function() end
- frame.UnregisterAllEvents = function() end
- frame.RegisterForClicks = function(self, ...)
- self._registeredClicks = { ... }
- end
- frame.HookScript = function(self, event, fn)
- self._hooks[event] = self._hooks[event] or {}
- self._hooks[event][#self._hooks[event] + 1] = fn
- end
- frame.RunHookScript = function(self, event, ...)
- for _, fn in ipairs(self._hooks[event] or {}) do
- fn(self, ...)
- end
- end
- frame.SetScript = function(self, event, fn)
- self._scripts[event] = fn
- end
- frame.GetScript = function(self, event)
- return self._scripts[event]
- end
- frame.SetAutoFocus = function() end
- frame.SetEnabled = function(self, enabled)
- self._enabled = enabled
- end
- frame.EnableMouse = function(self, enabled)
- self._mouseEnabled = enabled
- end
- frame.SetMaxLetters = function(self, value)
- self._maxLetters = value
- end
- frame.SetNumeric = function() end
- frame.SetJustifyH = function() end
- frame.SetJustifyV = function() end
- frame.SetWordWrap = function() end
- frame.SetTextInsets = function() end
- frame.SetSize = function(self, width, height)
- self:SetWidth(width)
- self:SetHeight(height)
- end
- frame.SetText = function(self, text)
- self._text = text
- end
- frame.GetText = function(self)
- return self._text
- end
- frame.SetFocus = function(self)
- self._focused = true
- end
- frame.ClearFocus = function(self)
- self._focused = false
- end
- frame.HighlightText = function(self)
- self._highlighted = true
- end
- return frame
- end
-
- local function loadLibraryWithHookStubs()
- local hooks = {}
-
- TestHelpers.SetupLibStub()
- TestHelpers.SetupSettingsStubs()
-
- _G.hooksecurefunc = function(target, method, fn)
- hooks[target] = hooks[target] or {}
- hooks[target][method] = fn
- end
-
- _G.SettingsListElementMixin = {}
- _G.SettingsDropdownControlMixin = {}
- _G.SettingsSliderControlMixin = {}
- _G.CreateFrame = function(_, _, _, template)
- local frame = createScriptableFrame()
- frame._template = template
- return frame
- end
-
- TestHelpers.LoadLibSettingsBuilder()
-
- return hooks, LibStub("LibSettingsBuilder-1.0")
- end
-
- setup(function()
- originalGlobals = TestHelpers.CaptureGlobals({
- "ECM_DeepEquals",
- "Settings",
- "SettingsPanel",
- "CreateSettingsListSectionHeaderInitializer",
- "CreateSettingsButtonInitializer",
- "MinimalSliderWithSteppersMixin",
- "CreateColor",
- "CreateColorFromHexString",
- "CreateFrame",
- "hooksecurefunc",
- "StaticPopupDialogs",
- "StaticPopup_Show",
- "YES",
- "NO",
- "UnitClass",
- "GetSpecialization",
- "GetSpecializationInfo",
- "LibStub",
- "CreateFromMixins",
- "SettingsListElementInitializer",
- "SettingsListElementMixin",
- "SettingsDropdownControlMixin",
- "SettingsSliderControlMixin",
- "C_Timer",
- "GameTooltip",
- "GameTooltip_Hide",
- "GameFontHighlight",
- "GameFontHighlightSmall",
- "GameFontNormal",
- })
- end)
-
- teardown(function()
- TestHelpers.RestoreGlobals(originalGlobals)
- end)
-
- before_each(function()
- layoutUpdateCalls = 0
- pendingTimers = {}
-
- TestHelpers.SetupLibStub()
- TestHelpers.SetupSettingsStubs()
- _G.CreateFrame = function(_, _, _, template)
- local frame = createScriptableFrame()
- frame._template = template
- return frame
- end
-
- _G.ECM_DeepEquals = TestHelpers.deepEquals
- _G.GameFontHighlight = "GameFontHighlight"
- _G.GameFontHighlightSmall = "GameFontHighlightSmall"
- _G.GameFontNormal = "GameFontNormal"
- _G.C_Timer = {
- NewTimer = function(_, callback)
- local timer = { callback = callback, cancelled = false }
- function timer:Cancel()
- self.cancelled = true
- end
- pendingTimers[#pendingTimers + 1] = timer
- return timer
- end,
- }
-
- _G.UnitClass = function()
- return "Warrior", "WARRIOR", 1
- end
- _G.GetSpecialization = function()
- return 1
- end
- _G.GetSpecializationInfo = function()
- return nil, "Arms"
- end
-
- -- Load the library
- TestHelpers.LoadLibSettingsBuilder()
-
- -- Register LSMW stub
- local lsmw = LibStub:NewLibrary("LibLSMSettingsWidgets-1.0", 1) or LibStub("LibLSMSettingsWidgets-1.0")
- lsmw.GetFontValues = function()
- return { Expressway = "Expressway" }
- end
- lsmw.GetStatusbarValues = function()
- return { Blizzard = "Blizzard" }
- end
- lsmw.FONT_PICKER_TEMPLATE = "TestFontPickerTemplate"
- lsmw.TEXTURE_PICKER_TEMPLATE = "TestTexturePickerTemplate"
-
- local profileData = {
- global = {
- hideWhenMounted = true,
- value = 5,
- mode = "solid",
- font = "Global Font",
- fontSize = 11,
- color = { r = 0.1, g = 0.2, b = 0.3, a = 1 },
- nested = { enabled = true },
- },
- powerBar = {
- enabled = true,
- height = 10,
- overrideFont = false,
- border = {
- enabled = false,
- thickness = 2,
- color = { r = 0, g = 0, b = 0, a = 1 },
- },
- anchorMode = 1,
- colors = {},
- },
- }
-
- addonNS = {
- Addon = {
- db = {
- profile = profileData,
- defaults = { profile = TestHelpers.deepClone(profileData) },
- },
- NewModule = function(_, name)
- return { moduleName = name }
- end,
- },
- Constants = {
- ANCHORMODE_CHAIN = 1,
- ANCHORMODE_FREE = 2,
- DEFAULT_BAR_WIDTH = 300,
- },
- L = setmetatable({}, { __index = function(_, key)
- return key
- end }),
- ColorUtil = {
- Sparkle = function(text)
- return text
- end,
- },
- CloneValue = TestHelpers.deepClone,
- Runtime = {
- ScheduleLayoutUpdate = function()
- layoutUpdateCalls = layoutUpdateCalls + 1
- end,
- },
- }
-
- TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, addonNS)
- TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, addonNS)
-
- SB = addonNS.SettingsBuilder
- setCurrentCategoryFromSection(SB, {
- key = "testSection",
- name = "TestSection",
- rows = {},
- }, "TestAddon")
- end)
-
- -- Category lifecycle
- it("GetRoot exposes registered sections and owned categories", function()
- local root = SB.GetRoot("TestAddon")
- local section = root:GetSection("testSection")
- local page = assert(section and section:GetPage("main"))
-
- assert.are.equal("TestAddon", root.name)
- assert.is_not_nil(section)
- assert.is_nil(root:GetSection("missingSection"))
- assert.is_true(root:HasCategory(page._category))
- assert.is_false(root:HasCategory({}))
- end)
-
- it("GetRoot reuses the singleton root handle", function()
- local rootA = SB.GetRoot("TestAddon")
- local rootB = SB.GetRoot("TestAddon")
-
- assert.are.equal(rootA, rootB)
- end)
-
- it("Setting current subcategory to root allows adding headers there", function()
- SB._currentSubcategory = SB._rootCategory
- local init = SB.Header("Root Header")
- assert.are.equal("header", init._type)
- assert.are.equal("Root Header", init._text)
- end)
-
- -- Checkbox
- it("Checkbox reads and writes profile value", function()
- local _, setting = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Hide",
- })
-
- assert.is_true(setting:GetValue())
-
- setting:SetValue(false)
- assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted)
- assert.are.equal(1, layoutUpdateCalls)
- end)
-
- it("Checkbox onSet callback is invoked on set", function()
- local onSetValue
- local _, setting = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Hide",
- onSet = function(v)
- onSetValue = v
- end,
- })
-
- setting:SetValue(false)
- assert.are.equal(false, onSetValue)
- end)
-
- -- Slider
- it("Slider reads/writes with getTransform and setTransform", function()
- local _, setting = SB.Slider({
- path = "powerBar.height",
- name = "Height",
- min = 0,
- max = 40,
- step = 1,
- getTransform = function(v)
- return v or 0
- end,
- setTransform = function(v)
- return v > 0 and v or nil
- end,
- })
-
- assert.are.equal(10, setting:GetValue())
-
- setting:SetValue(0)
- assert.is_nil(addonNS.Addon.db.profile.powerBar.height)
- end)
-
- it("Slider applies default formatter when none specified", function()
- local capturedOpts
- local settings = Settings
- local origCreate = settings.CreateSlider
- rawset(settings, "CreateSlider", function(cat, setting, options, tooltip)
- capturedOpts = options
- return origCreate(cat, setting, options, tooltip)
- end)
-
- SB.Slider({
- path = "global.value",
- name = "Value",
- min = 0,
- max = 10,
- step = 1,
- })
-
- rawset(settings, "CreateSlider", origCreate)
-
- assert.are.equal(MinimalSliderWithSteppersMixin.Label.Right, capturedOpts._labelFormatterLocation)
- -- Default formatter renders integers without decimals
- assert.are.equal("5", capturedOpts._labelFormatter(5))
- assert.are.equal("0", capturedOpts._labelFormatter(0))
- -- Default formatter renders fractional values with one decimal
- assert.are.equal("2.5", capturedOpts._labelFormatter(2.5))
- end)
-
- it("Slider uses custom formatter when specified", function()
- local capturedOpts
- local settings = Settings
- local origCreate = settings.CreateSlider
- rawset(settings, "CreateSlider", function(cat, setting, options, tooltip)
- capturedOpts = options
- return origCreate(cat, setting, options, tooltip)
- end)
-
- local customFormatter = function(value)
- return value .. "%%"
- end
- SB.Slider({
- path = "global.value",
- name = "Value",
- min = 0,
- max = 100,
- step = 5,
- formatter = customFormatter,
- })
-
- rawset(settings, "CreateSlider", origCreate)
-
- assert.are.equal(customFormatter, capturedOpts._labelFormatter)
- end)
-
- -- Dropdown
- it("Dropdown creates dropdown with values", function()
- local _, setting = SB.Dropdown({
- path = "global.mode",
- name = "Mode",
- values = { solid = "Solid", flat = "Flat" },
- })
-
- assert.are.equal("solid", setting:GetValue())
-
- setting:SetValue("flat")
- assert.are.equal("flat", addonNS.Addon.db.profile.global.mode)
- end)
-
- -- Color
- it("Color reads/writes color as AARRGGBB hex", function()
- local _, setting = SB.Color({
- path = "global.color",
- name = "Color",
- })
-
- local hex = setting:GetValue()
- assert.are.equal("FF1A334D", hex)
-
- -- Verify round-trip: hex -> table stored in profile
- setting:SetValue("FF66809A")
- local stored = addonNS.Addon.db.profile.global.color
- assert.are.equal(0.4, math.floor(stored.r * 255 + 0.5) / 255)
- end)
-
- -- Control dispatcher
- it("Control dispatches to checkbox", function()
- local _, setting = SB.Control({
- type = "checkbox",
- path = "global.hideWhenMounted",
- name = "Hide",
- })
- assert.is_true(setting:GetValue())
- end)
-
- it("Control dispatches to slider", function()
- local _, setting = SB.Control({
- type = "slider",
- path = "global.value",
- name = "Value",
- min = 0,
- max = 10,
- step = 1,
- })
- assert.are.equal(5, setting:GetValue())
- end)
-
- it("Control dispatches to dropdown", function()
- local _, setting = SB.Control({
- type = "dropdown",
- path = "global.mode",
- name = "Mode",
- values = { solid = "Solid" },
- })
- assert.are.equal("solid", setting:GetValue())
- end)
-
- it("Control dispatches to color", function()
- local _, setting = SB.Control({
- type = "color",
- path = "global.color",
- name = "Color",
- })
- local hex = setting:GetValue()
- assert.are.equal("string", type(hex))
- assert.are.equal(8, #hex)
- end)
-
- it("Control errors on unknown type", function()
- assert.has_error(function()
- SB.Control({ type = "bogus", path = "x", name = "X" })
- end)
- end)
-
- -- layout=false
- it("layout=false skips ScheduleLayoutUpdate", function()
- local _, setting = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Hide",
- layout = false,
- })
- setting:SetValue(false)
- assert.are.equal(0, layoutUpdateCalls)
- end)
-
- -- Header
- it("Header adds initializer to current layout", function()
- local init = SB.Header("Test Header")
- assert.are.equal("header", init._type)
- assert.are.equal("Test Header", init._text)
- end)
-
- it("CreateSubheaderTitle applies the standard subheader styling", function()
- local parent = createScriptableFrame()
- parent.CreateFontString = function(_, _, _, fontTemplate)
- local fontString = createScriptableFrame()
- fontString._fontTemplate = fontTemplate
- fontString.SetFontObject = function(self, value)
- self._fontObject = value
- end
- fontString.SetJustifyH = function(self, value)
- self._justifyH = value
- end
- fontString.SetJustifyV = function(self, value)
- self._justifyV = value
- end
- return fontString
- end
-
- local title = SB.CreateSubheaderTitle(parent, "Viewer Icons")
- local point, relativeTo, relativePoint, x, y = title:GetPoint(1)
-
- assert.are.equal("GameFontHighlightSmall", title._fontTemplate)
- assert.are.equal("TOPLEFT", point)
- assert.are.equal(parent, relativeTo)
- assert.are.equal("TOPLEFT", relativePoint)
- assert.are.equal(35, x)
- assert.are.equal(-8, y)
- assert.are.equal("LEFT", title._justifyH)
- assert.are.equal("TOP", title._justifyV)
- assert.are.equal("GameFontHighlight", title._fontObject)
- assert.are.equal("Viewer Icons", title:GetText())
- assert.is_true(title:IsShown())
- end)
-
- it("CreateHeaderTitle applies the standard header styling", function()
- local parent = createScriptableFrame()
- parent.CreateFontString = function(_, _, _, fontTemplate)
- local fontString = createScriptableFrame()
- fontString._fontTemplate = fontTemplate
- fontString.SetJustifyH = function(self, value)
- self._justifyH = value
- end
- fontString.SetJustifyV = function(self, value)
- self._justifyV = value
- end
- return fontString
- end
-
- local title = SB.CreateHeaderTitle(parent, "Viewer Icons")
- local point, relativeTo, relativePoint, x, y = title:GetPoint(1)
-
- assert.are.equal("GameFontHighlightLarge", title._fontTemplate)
- assert.are.equal("TOPLEFT", point)
- assert.are.equal(parent, relativeTo)
- assert.are.equal("TOPLEFT", relativePoint)
- assert.are.equal(7, x)
- assert.are.equal(-16, y)
- assert.are.equal("LEFT", title._justifyH)
- assert.are.equal("TOP", title._justifyV)
- assert.are.equal("Viewer Icons", title:GetText())
- assert.is_true(title:IsShown())
- end)
-
- -- Subheader
- it("Subheader adds element initializer with normal font template", function()
- local init = SB.Subheader({ name = "Item Quality" })
- assert.are.equal(SB.SUBHEADER_TEMPLATE, init._template)
- assert.are.equal("Item Quality", init.data.name)
- end)
-
- it("Subheader respects explicit category via root subcategory", function()
- SB._currentSubcategory = SB._rootCategory
- local init = SB.Subheader({ name = "Root Sub" })
- assert.are.equal("Root Sub", init.data.name)
- end)
-
- it("Subheader as parent — isParentEnabled returns true", function()
- local labelInit = SB.Subheader({ name = "Colors" })
- local childInit = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Child",
- parent = labelInit,
- })
- -- Labels have no GetSetting, so isParentEnabled should return true
- local enabledPredicate = childInit._modifyPredicates[1]
- assert.is_true(enabledPredicate())
- end)
-
- -- InfoRow
- it("InfoRow adds element initializer with template and data", function()
- local init = SB.InfoRow({ name = "Author", value = "TestUser" })
- assert.are.equal(SB.INFOROW_TEMPLATE, init._template)
- assert.are.equal("Author", init.data.name)
- assert.are.equal("TestUser", init.data.value)
- end)
-
- it("InfoRow falls back to GetExtent when SetExtent is unavailable", function()
- local settings = Settings
- local originalCreateElementInitializer = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(frameTemplate, data)
- local init = originalCreateElementInitializer(frameTemplate, data)
- init.SetExtent = nil
- return init
- end)
-
- local init = SB.InfoRow({ name = "Author", value = "TestUser" })
-
- rawset(settings, "CreateElementInitializer", originalCreateElementInitializer)
-
- assert.are.equal(26, init:GetExtent())
- end)
-
- it("InfoRow respects explicit category", function()
- SB._currentSubcategory = SB._rootCategory
- local init = SB.InfoRow({ name = "Version", value = "1.0" })
- assert.are.equal("Version", init.data.name)
- assert.are.equal("1.0", init.data.value)
- end)
-
- it("InfoRow supports hidden modifier", function()
- local hidden = true
- local init = SB.InfoRow({
- name = "Secret",
- value = "x",
- hidden = function()
- return hidden
- end,
- })
- assert.is_not_nil(init._shownPredicates)
- assert.are.equal(1, #init._shownPredicates)
- end)
-
- it("Input creates an input row initializer and writes string values", function()
- local init, setting = SB.Input({
- path = "global.font",
- name = "Entry ID",
- layout = false,
- })
-
- assert.are.equal(SB.INPUTROW_TEMPLATE, init._template)
- assert.are.equal("Global Font", setting:GetValue())
-
- setting:SetValue("12345")
- assert.are.equal("12345", addonNS.Addon.db.profile.global.font)
- end)
-
- it("Input rows debounce preview text and refresh when watched settings change", function()
- local currentKind = "spell"
- local draftId = ""
- local _, kindSetting = SB.Dropdown({
- get = function()
- return currentKind
- end,
- set = function(value)
- currentKind = value
- end,
- key = "kind",
- default = "spell",
- name = "Kind",
- values = { spell = "Spell", item = "Item" },
- layout = false,
- })
-
- local inputInit = SB.Input({
- get = function()
- return draftId
- end,
- set = function(value)
- draftId = value
- end,
- key = "draftId",
- default = "",
- name = "Entry ID",
- debounce = 1,
- layout = false,
- resolveText = function(text)
- if not text or text == "" then
- return nil
- end
- return currentKind .. ":" .. text
- end,
- watch = { "kind" },
- })
-
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetJustifyH = function() end
- fontString.SetJustifyV = function() end
- fontString.SetWordWrap = function() end
- return fontString
- end
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- inputInit:InitFrame(frame)
-
- local editBox = frame._lsbInputEditBox
- editBox:SetText("123")
- editBox:GetScript("OnTextChanged")(editBox)
-
- assert.are.equal("123", draftId)
- assert.are.equal("", frame._lsbInputPreview:GetText())
-
- runPendingTimers()
- assert.are.equal("spell:123", frame._lsbInputPreview:GetText())
-
- kindSetting:SetValue("item")
- assert.are.equal("item:123", frame._lsbInputPreview:GetText())
- end)
-
- it("custom list rows initialize safely without preexisting cbrHandles", function()
- local function makeListElementFrame()
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- fontString.SetJustifyH = function() end
- fontString.SetJustifyV = function() end
- return fontString
- end
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
- return frame
- end
-
- local subheader = SB.Subheader({ name = "Item Quality" })
- local subheaderFrame = makeListElementFrame()
-
- assert.has_no.errors(function()
- subheader:InitFrame(subheaderFrame)
- end)
- assert.is_not_nil(subheaderFrame.cbrHandles)
- assert.are.equal("Item Quality", subheaderFrame._lsbSubheaderTitle:GetText())
-
- subheader:Resetter(subheaderFrame)
- assert.is_true(subheaderFrame.cbrHandles._unregistered)
-
- local canvas = createScriptableFrame()
- canvas.SetParent = function(self, parent)
- self._parent = parent
- end
- canvas.GetParent = function(self)
- return self._parent
- end
- local embed = SB.EmbedCanvas(canvas, 120)
- local embedFrame = makeListElementFrame()
-
- assert.has_no.errors(function()
- embed:InitFrame(embedFrame)
- end)
- assert.are.equal(embedFrame, canvas:GetParent())
- assert.are.equal(120, canvas:GetHeight())
- end)
-
- it("plain list rows hide recycled custom children and preview regions", function()
- local function makeListElementFrame()
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- fontString.SetJustifyH = function() end
- fontString.SetJustifyV = function() end
- return fontString
- end
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
- return frame
- end
-
- local subheaderFrame = makeListElementFrame()
- local subheaderForeignChild = createScriptableFrame()
- local subheaderForeignRegion = createScriptableFrame()
- subheaderFrame.GetChildren = function()
- return subheaderForeignChild
- end
- subheaderFrame.GetRegions = function()
- return subheaderForeignRegion
- end
-
- local subheader = SB.Subheader({ name = "Item Quality" })
- subheader:InitFrame(subheaderFrame)
-
- assert.is_false(subheaderForeignChild:IsShown())
- assert.is_false(subheaderForeignRegion:IsShown())
-
- local canvas = createScriptableFrame()
- canvas.SetParent = function(self, parent)
- self._parent = parent
- end
- canvas.GetParent = function(self)
- return self._parent
- end
-
- local embedFrame = makeListElementFrame()
- local embedForeignChild = createScriptableFrame()
- local embedForeignRegion = createScriptableFrame()
- embedFrame.GetChildren = function()
- return embedForeignChild
- end
- embedFrame.GetRegions = function()
- return embedForeignRegion
- end
-
- local embed = SB.EmbedCanvas(canvas, 120)
- embed:InitFrame(embedFrame)
-
- assert.is_false(embedForeignChild:IsShown())
- assert.is_false(embedForeignRegion:IsShown())
- assert.are.equal(embedFrame, canvas:GetParent())
- end)
-
- it("canvas rows hide the previous canvas when the frame is reused on another page", function()
- local function makeListElementFrame()
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- fontString.SetJustifyH = function() end
- fontString.SetJustifyV = function() end
- return fontString
- end
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
- return frame
- end
-
- local canvasA = createScriptableFrame()
- canvasA.SetParent = function(self, parent)
- self._parent = parent
- end
- canvasA.GetParent = function(self)
- return self._parent
- end
-
- local canvasB = createScriptableFrame()
- canvasB.SetParent = function(self, parent)
- self._parent = parent
- end
- canvasB.GetParent = function(self)
- return self._parent
- end
-
- local canvasRowA = SB.EmbedCanvas(canvasA, 120)
- local canvasRowB = SB.EmbedCanvas(canvasB, 140)
- local subheader = SB.Subheader({ name = "Reused Frame" })
- local frame = makeListElementFrame()
-
- canvasRowA:InitFrame(frame)
- assert.is_true(canvasA:IsShown())
- assert.are.equal(frame, canvasA:GetParent())
-
- canvasRowA:Resetter(frame)
- assert.is_false(canvasA:IsShown())
- assert.is_nil(frame._lsbCanvas)
-
- subheader:InitFrame(frame)
- assert.is_false(canvasA:IsShown())
-
- subheader:Resetter(frame)
-
- canvasRowB:InitFrame(frame)
- assert.is_false(canvasA:IsShown())
- assert.is_true(canvasB:IsShown())
- assert.are.equal(frame, canvasB:GetParent())
-
- canvasRowB:Resetter(frame)
- assert.is_false(canvasB:IsShown())
-
- canvasRowA:InitFrame(frame)
- assert.is_true(canvasA:IsShown())
- assert.are.equal(frame, canvasA:GetParent())
- end)
-
- -- Button
- it("Button creates button initializer with onClick", function()
- local clicked = false
- local init = SB.Button({
- name = "Do it",
- buttonText = "Click",
- onClick = function()
- clicked = true
- end,
- })
- assert.are.equal("button", init._type)
- init._onClick()
- assert.is_true(clicked)
- end)
-
- it("Button confirm wraps onClick in StaticPopup", function()
- local clicked = false
- SB.Button({
- name = "Danger",
- buttonText = "Reset",
- confirm = "Are you sure?",
- onClick = function()
- clicked = true
- end,
- })
-
- -- The shared confirm dialog should exist
- local dialogName = "ECM_LibSettingsBuilder_1_0_SettingsConfirm"
- local dialog = StaticPopupDialogs[dialogName]
- assert.is_table(dialog)
-
- -- Simulate accepting the popup with the data that onClick passes
- dialog.OnAccept(nil, { onAccept = function() clicked = true end })
- assert.is_true(clicked)
- end)
-
- it("Button confirm uses a shared dialog with per-button data", function()
- local getShownNames = TestHelpers.InstallPopupRecorder()
-
- local clicked = {}
- local resetButton = SB.Button({
- name = "Reset",
- confirm = "Reset everything?",
- onClick = function()
- clicked[#clicked + 1] = "reset"
- end,
- })
- local deleteButton = SB.Button({
- name = "Delete",
- confirm = "Delete profile?",
- onClick = function()
- clicked[#clicked + 1] = "delete"
- end,
- })
-
- resetButton._onClick()
- deleteButton._onClick()
-
- local shownNames = getShownNames()
-
- -- Both use the same shared dialog
- assert.are.equal(2, #shownNames)
- assert.are.equal(shownNames[1], shownNames[2])
-
- -- Verify the shared dialog's OnAccept dispatches correctly via data
- local dialogName = shownNames[1]
- local dialog = StaticPopupDialogs[dialogName]
- assert.is_table(dialog)
-
- dialog.OnAccept(nil, { onAccept = function() clicked[#clicked + 1] = "reset" end })
- dialog.OnAccept(nil, { onAccept = function() clicked[#clicked + 1] = "delete" end })
- assert.are.same({ "reset", "delete" }, clicked)
- end)
-
- -- ApplyModifiers
- it("ApplyModifiers sets parent, disabled, and hidden predicates", function()
- local parentInit, _ = SB.Checkbox({
- path = "global.nested.enabled",
- name = "Parent",
- })
- local childInit, _ = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Child",
- parent = parentInit,
- parentCheck = function()
- return true
- end,
- disabled = function()
- return true
- end,
- hidden = function()
- return false
- end,
- })
-
- assert.are.equal(parentInit, childInit._parentInit)
- assert.are.equal(1, #childInit._modifyPredicates)
- assert.are.equal(1, #childInit._shownPredicates)
- end)
-
- it("Parent-controlled dropdown is disabled when parent is unchecked", function()
- local parentInit, parentSetting = SB.Checkbox({
- path = "global.nested.enabled",
- name = "Parent",
- })
-
- local childInit = SB.Dropdown({
- path = "global.mode",
- name = "Child",
- values = { solid = "Solid", flat = "Flat" },
- parent = parentInit,
- parentCheck = function()
- return parentSetting:GetValue()
- end,
- })
-
- local enabledPredicate = childInit._modifyPredicates[1]
- assert.is_true(enabledPredicate())
-
- parentSetting:SetValue(false)
- assert.is_false(enabledPredicate())
- end)
-
- it("Parent-controlled custom picker is disabled when parent is unchecked", function()
- local parentInit, parentSetting = SB.Checkbox({
- path = "global.nested.enabled",
- name = "Parent",
- })
-
- local customEnabled
- local settings = Settings
- local originalCreateElementInitializer = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(frameTemplate, data)
- local init = originalCreateElementInitializer(frameTemplate, data)
- init.SetEnabled = function(_, enabled)
- customEnabled = enabled
- end
- return init
- end)
-
- local childInit = SB.Custom({
- path = "global.font",
- name = "Custom picker",
- template = "TestTexturePickerTemplate",
- parent = parentInit,
- parentCheck = function()
- return parentSetting:GetValue()
- end,
- })
-
- rawset(settings, "CreateElementInitializer", originalCreateElementInitializer)
-
- local enabledPredicate = childInit._modifyPredicates[1]
- assert.is_true(customEnabled)
- assert.is_true(enabledPredicate())
-
- parentSetting:SetValue(false)
- assert.is_false(enabledPredicate())
- assert.is_false(customEnabled)
- end)
-
- -- Reactive disabled predicate
- it("disabled predicate re-evaluates when another setting changes", function()
- local frames = createSettingsPanelMock()
-
- local _, enabledSetting = SB.Checkbox({
- path = "powerBar.enabled",
- name = "Enable",
- })
-
- local childInit
- local controlEnabled
- local settings = Settings
- local origCreateCheckbox = settings.CreateCheckbox
- rawset(settings, "CreateCheckbox", function(cat, setting, tooltip)
- local init = origCreateCheckbox(cat, setting, tooltip)
- childInit = init
- return init
- end)
-
- SB.Checkbox({
- path = "powerBar.showText",
- name = "Show text",
- disabled = function()
- return not addonNS.Addon.db.profile.powerBar.enabled
- end,
- })
-
- rawset(settings, "CreateCheckbox", origCreateCheckbox)
-
- -- Simulate a rendered frame for the child control
- frames[1] = {
- GetElementData = function()
- return childInit
- end,
- IsEnabled = function(self)
- return self:GetElementData():EvaluateModifyPredicates()
- end,
- EvaluateState = function(self)
- controlEnabled = self:IsEnabled()
- end,
- SetShown = function() end,
- }
- -- Verify initial state
- frames[1]:EvaluateState()
- assert.is_true(controlEnabled)
-
- enabledSetting:SetValue(false)
- assert.is_false(controlEnabled)
-
- enabledSetting:SetValue(true)
- assert.is_true(controlEnabled)
-
- _G.SettingsPanel = nil
- end)
-
- -- Reactive hidden predicate
- it("hidden predicate re-evaluates when another setting changes", function()
- local frames = createSettingsPanelMock()
-
- local _, toggleSetting = SB.Checkbox({
- path = "powerBar.enabled",
- name = "Enable",
- })
-
- local childInit
- local settings = Settings
- local origCreateCheckbox = settings.CreateCheckbox
- rawset(settings, "CreateCheckbox", function(cat, setting, tooltip)
- local init = origCreateCheckbox(cat, setting, tooltip)
- childInit = init
- return init
- end)
-
- SB.Checkbox({
- path = "powerBar.showText",
- name = "Show text",
- hidden = function()
- return not addonNS.Addon.db.profile.powerBar.enabled
- end,
- })
-
- rawset(settings, "CreateCheckbox", origCreateCheckbox)
-
- -- Initial state: enabled=true, so hidden()=false → shown
- local shownPredicate = childInit._shownPredicates[1]
- assert.is_true(shownPredicate())
-
- -- Simulate a rendered frame that checks ShouldShow
- local frameShown = true
- childInit.ShouldShow = function()
- return not childInit._shownPredicates[1] or childInit._shownPredicates[1]()
- end
- frames[1] = {
- GetElementData = function()
- return childInit
- end,
- EvaluateState = function(self)
- frameShown = self:GetElementData():ShouldShow()
- end,
- }
- frames[1]:EvaluateState()
- assert.is_true(frameShown)
-
- toggleSetting:SetValue(false)
- assert.is_false(frameShown)
-
- toggleSetting:SetValue(true)
- assert.is_true(frameShown)
-
- _G.SettingsPanel = nil
- end)
-
- -- HeightOverrideSlider
- it("HeightOverrideSlider transforms nil→0 and 0→nil", function()
- local _, setting = SB.HeightOverrideSlider("powerBar")
-
- assert.are.equal(10, setting:GetValue())
-
- setting:SetValue(0)
- assert.is_nil(addonNS.Addon.db.profile.powerBar.height)
- assert.are.equal(0, setting:GetValue())
- end)
-
- -- BorderGroup
- it("BorderGroup creates enabled, thickness, color controls", function()
- local result = SB.BorderGroup("powerBar.border")
- assert.is_not_nil(result.enabledInit)
- assert.is_not_nil(result.enabledSetting)
- assert.is_not_nil(result.thicknessInit)
- assert.is_not_nil(result.colorInit)
- end)
-
- -- FontOverrideGroup
- it("FontOverrideGroup creates override checkbox, font dropdown, size slider", function()
- local result = SB.FontOverrideGroup("powerBar")
- assert.is_not_nil(result.enabledInit)
- assert.is_not_nil(result.enabledSetting)
- assert.is_not_nil(result.fontInit)
- assert.is_not_nil(result.sizeInit)
- end)
-
- it("FontOverrideGroup children are disabled when override is unchecked", function()
- addonNS.Addon.db.profile.powerBar.overrideFont = false
- local result = SB.FontOverrideGroup("powerBar")
-
- -- Font and size children should have modify predicates (disabled, not hidden)
- assert.is_truthy(result.fontInit._modifyPredicates)
- assert.is_truthy(result.sizeInit._modifyPredicates)
- assert.is_nil(result.fontInit._parentInit)
- assert.is_nil(result.sizeInit._parentInit)
-
- local fontPredicate = result.fontInit._modifyPredicates[1]
- local sizePredicate = result.sizeInit._modifyPredicates[1]
-
- -- Override is false → children disabled
- assert.is_false(fontPredicate())
- assert.is_false(sizePredicate())
-
- -- Toggle override on → children enabled
- result.enabledSetting:SetValue(true)
- assert.is_true(fontPredicate())
- assert.is_true(sizePredicate())
- end)
-
- -- ColorPickerList
- it("ColorPickerList creates native color swatch per definition", function()
- addonNS.Addon.db.profile.powerBar.colors = {
- [0] = { r = 0, g = 0, b = 1, a = 1 },
- }
- addonNS.Addon.db.defaults.profile.powerBar.colors = {
- [0] = { r = 0, g = 0, b = 1, a = 1 },
- }
-
- local defs = {
- { key = 0, name = "Mana" },
- { key = 1, name = "Rage" },
- }
- local results = SB.ColorPickerList("powerBar.colors", defs)
- assert.are.equal(2, #results)
- assert.are.equal(0, results[1].key)
- assert.are.equal(1, results[2].key)
- assert.is_not_nil(results[1].initializer)
- assert.is_not_nil(results[1].setting)
- assert.is_not_nil(results[2].initializer)
- assert.is_not_nil(results[2].setting)
- end)
-
- -- Built-in path accessors
- it("path accessors read and write nested values", function()
- local SB2 = createSB2("TEST2", "Test2")
- local _, page = setCurrentCategoryFromSection(SB2, {
- key = "sub2",
- name = "Sub2",
- rows = {},
- }, "Test2")
- SB2._currentSubcategory = page._category
-
- local _, setting = SB2.Checkbox({
- path = "global.hideWhenMounted",
- name = "Hide",
- })
- assert.is_true(setting:GetValue())
-
- setting:SetValue(false)
- assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted)
- end)
-
- it("built-in path accessors handle numeric keys", function()
- addonNS.Addon.db.profile.powerBar.colors[0] = { r = 0, g = 0, b = 1, a = 1 }
- addonNS.Addon.db.defaults.profile.powerBar.colors[0] = { r = 0, g = 0, b = 1, a = 1 }
-
- local SB2 = createSB2("TEST3", "Test3")
- local _, page = setCurrentCategoryFromSection(SB2, {
- key = "sub3",
- name = "Sub3",
- rows = {},
- }, "Test3")
- SB2._currentSubcategory = page._category
-
- local _, setting = SB2.Color({
- path = "powerBar.colors.0",
- name = "Mana",
- })
- local hex = setting:GetValue()
- assert.are.equal("string", type(hex))
- assert.are.equal(8, #hex)
- end)
-
- -- Header "Display" no longer suppressed
- it("Header('Display') returns initializer (no longer suppressed)", function()
- local init = SB.Header("Display")
- assert.are.equal("header", init._type)
- assert.are.equal("Display", init._text)
- end)
-
- it("Header matching subcategory name still returns a normal header", function()
- local _, page = setCurrentCategoryFromSection(SB, {
- key = "appearance",
- name = "Appearance",
- rows = {},
- }, "TestAddon")
- SB._currentSubcategory = page._category
- local init = SB.Header("Appearance")
- assert.are.equal("header", init._type)
- assert.are.equal("Appearance", init._text)
- end)
-
- it("Header accepts hidden predicates through spec tables", function()
- local init = SB.Header({
- name = "Quick Add",
- hidden = function()
- return true
- end,
- })
-
- assert.are.equal("header", init._type)
- assert.are.equal(1, #(init._shownPredicates or {}))
- assert.is_false(init._shownPredicates[1]())
- end)
-
- -- Custom with varType override
- it("Custom respects varType override", function()
- local capturedVarType
- local settings = Settings
- local origRegister = settings.RegisterProxySetting
- rawset(settings, "RegisterProxySetting", function(cat, variable, varType, name, default, getter, setter)
- capturedVarType = varType
- return origRegister(cat, variable, varType, name, default, getter, setter)
- end)
-
- SB.Custom({
- path = "global.value",
- name = "Custom Numeric",
- template = "TestTemplate",
- varType = Settings.VarType.Number,
- })
-
- rawset(settings, "RegisterProxySetting", origRegister)
- assert.are.equal(Settings.VarType.Number, capturedVarType)
- end)
-
- -- propagateModifiers with layout
- it("propagateModifiers propagates layout=false to composite children", function()
- SB.HeightOverrideSlider("powerBar", { layout = false })
- -- Since layout=false is propagated, the onChanged check should skip layout
- -- We verify by setting the value and checking layoutUpdateCalls stays 0
- -- (Need to reload to test with onChanged that checks layout)
- end)
-
- -- Spec field validation
- it("debug spec validation warns on unknown fields", function()
- local warnings = {}
- local origPrint = print
- _G.print = function(msg)
- if type(msg) == "string" and msg:find("LibSettingsBuilder WARNING") then
- warnings[#warnings + 1] = msg
- end
- end
- _G.LSB_DEBUG = true
-
- SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Test",
- bogusField = true,
- })
-
- _G.LSB_DEBUG = nil
- _G.print = origPrint
-
- assert.is_true(#warnings > 0)
- assert.is_truthy(warnings[1]:find("bogusField"))
- end)
-
- it("debug spec validation is silent when LSB_DEBUG is off", function()
- local warnings = {}
- local origPrint = print
- _G.print = function(msg)
- if type(msg) == "string" and msg:find("LibSettingsBuilder WARNING") then
- warnings[#warnings + 1] = msg
- end
- end
- _G.LSB_DEBUG = nil
-
- SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Test",
- bogusField = true,
- })
-
- _G.print = origPrint
- assert.are.equal(0, #warnings)
- end)
-
- it("Dropdown with scrollHeight keeps Blizzard's native dropdown options callback", function()
- local init, setting = SB.Dropdown({
- path = "global.mode",
- name = "Scrollable Mode",
- values = { solid = "Solid", flat = "Flat" },
- scrollHeight = 300,
- })
-
- assert.is_function(init._optionsGen)
- assert.are.equal("scrollDropdown", init._lsbData._lsbKind)
- assert.are.equal(300, init._lsbData.scrollHeight)
- assert.are.equal(setting, init:GetSetting())
- assert.are.equal("solid", setting:GetValue())
-
- setting:SetValue("flat")
- assert.are.equal("flat", addonNS.Addon.db.profile.global.mode)
- end)
-
- it("Dropdown without scrollHeight uses standard dropdown", function()
- local capturedTemplate = nil
- local settings = Settings
- local origCreateElementInitializer = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(template, data)
- capturedTemplate = template
- return origCreateElementInitializer(template, data)
- end)
-
- SB.Dropdown({
- path = "global.mode",
- name = "Standard Mode",
- values = { solid = "Solid", flat = "Flat" },
- })
-
- rawset(settings, "CreateElementInitializer", origCreateElementInitializer)
-
- -- Standard path uses Settings.CreateDropdown, not CreateElementInitializer
- -- with the scroll template
- assert.is_not_equal(SB.SCROLL_DROPDOWN_TEMPLATE, capturedTemplate)
- end)
-
- it("Dropdown options are added in deterministic label order", function()
- local init = SB.Dropdown({
- path = "global.mode",
- name = "Standard Mode",
- values = {
- gamma = "Gamma",
- alpha = "Alpha",
- beta = "Beta",
- },
- })
-
- local options = init._optionsGen()
- assert.are.same({ "Alpha", "Beta", "Gamma" }, {
- options[1].label,
- options[2].label,
- options[3].label,
- })
- assert.are.same({ "alpha", "beta", "gamma" }, {
- options[1].value,
- options[2].value,
- options[3].value,
- })
- end)
-
- it("scroll dropdown menu options are added in deterministic label order", function()
- local hooks = select(1, loadLibraryWithHookStubs())
- local initHook = hooks[_G.SettingsDropdownControlMixin].Init
-
- local currentValue = "beta"
- local setting = {
- GetValue = function()
- return currentValue
- end,
- SetValue = function(_, value)
- currentValue = value
- end,
- }
-
- local dropdown = {
- SetupMenu = function(self, builder)
- self._builder = builder
- end,
- OverrideText = function(self, text)
- self._text = text
- end,
- }
- local frame = {
- Control = { Dropdown = dropdown },
- SetValue = function() end,
- }
- local initializer = {
- _lsbData = {
- _lsbKind = "scrollDropdown",
- setting = setting,
- values = {
- gamma = "Gamma",
- alpha = "Alpha",
- beta = "Beta",
- },
- scrollHeight = 240,
- },
- GetSetting = function()
- return setting
- end,
- }
-
- initHook(frame, initializer)
-
- local orderedLabels = {}
- local rootDescription = {
- SetScrollMode = function(_, value)
- orderedLabels.scrollHeight = value
- end,
- CreateRadio = function(_, label)
- orderedLabels[#orderedLabels + 1] = label
- end,
- }
- dropdown._builder(nil, rootDescription)
-
- assert.are.equal("Beta", dropdown._text)
- assert.are.equal(240, orderedLabels.scrollHeight)
- assert.are.same({ "Alpha", "Beta", "Gamma" }, {
- orderedLabels[1],
- orderedLabels[2],
- orderedLabels[3],
- })
- end)
-
- -- Declarative root registration
- it("root:Register creates section pages and controls from ordered rows", function()
- local SB2 = createSB2("TBL1", "TableTest")
- local root = SB2.GetRoot("TableTest")
-
- root:Register({
- sections = {
- {
- key = "testSection",
- name = "Test Section",
- path = "global",
- rows = {
- { id = "header1", type = "header", name = "Visibility" },
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
- { id = "val", type = "slider", path = "value", name = "Value", min = 0, max = 10, step = 1 },
- { id = "mode", type = "dropdown", path = "mode", name = "Mode", values = { solid = "Solid" } },
- },
- },
- },
- })
-
- local page = root:GetSection("testSection"):GetPage("main")
- assert.is_not_nil(page)
- assert.is_true(root:HasCategory(page._category))
- end)
-
- it("root:Register inherits disabled from the page spec", function()
- local disabledFn = function()
- return true
- end
- local SB2 = createSB2("TBL2", "InheritTest")
-
- assert.has_no.errors(function()
- SB2.GetRoot("InheritTest"):Register({
- sections = {
- {
- key = "inheritSection",
- name = "Inherit Section",
- path = "global",
- disabled = disabledFn,
- rows = {
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
- },
- },
- },
- })
- end)
- end)
-
- it("root:Register resolves parent references by row id", function()
- local SB2 = createSB2("TBL3", "ParentRefTest")
-
- assert.has_no.errors(function()
- SB2.GetRoot("ParentRefTest"):Register({
- sections = {
- {
- key = "parentRefSection",
- name = "Parent Ref Section",
- path = "global",
- rows = {
- { id = "parentCtrl", type = "checkbox", path = "hideWhenMounted", name = "Parent" },
- {
- id = "childCtrl",
- type = "slider",
- path = "value",
- name = "Child",
- min = 0,
- max = 10,
- step = 1,
- parent = "parentCtrl",
- parentCheck = "checked",
- },
- },
- },
- },
- })
- end)
- end)
-
- it("root:Register accepts canonical row types only", function()
- local SB2 = createSB2("TBL4", "AliasTest")
-
- assert.has_no.errors(function()
- SB2.GetRoot("AliasTest"):Register({
- sections = {
- {
- key = "aliasSection",
- name = "Alias Section",
- path = "global",
- rows = {
- { id = "t", type = "checkbox", path = "hideWhenMounted", name = "Toggle" },
- { id = "r", type = "slider", path = "value", name = "Range", min = 0, max = 10, step = 1 },
- { id = "s", type = "dropdown", path = "mode", name = "Select", values = { solid = "Solid" } },
- { id = "h", type = "header", name = "Header" },
- { id = "d", type = "subheader", name = "Desc" },
- { id = "i", type = "info", name = "Author", value = "Test" },
- },
- },
- },
- })
- end)
- end)
-
- it("root:Register supports desc as alias for tooltip", function()
- local capturedTooltip
- local settings = Settings
- local origCreateCheckbox = settings.CreateCheckbox
- rawset(settings, "CreateCheckbox", function(cat, setting, tooltip)
- capturedTooltip = tooltip
- return origCreateCheckbox(cat, setting, tooltip)
- end)
-
- local SB2 = createSB2("TBL5", "DescTest")
-
- SB2.GetRoot("DescTest"):Register({
- sections = {
- {
- key = "descSection",
- name = "Desc Section",
- path = "global",
- rows = {
- {
- id = "mounted",
- type = "checkbox",
- path = "hideWhenMounted",
- name = "Hide",
- desc = "Hide when on a mount.",
- },
- },
- },
- },
- })
-
- rawset(settings, "CreateCheckbox", origCreateCheckbox)
- assert.are.equal("Hide when on a mount.", capturedTooltip)
- end)
-
- it("root:Register applies section path prefixing", function()
- local SB2 = createSB2("TBL7", "PrefixTest")
-
- SB2.GetRoot("PrefixTest"):Register({
- sections = {
- {
- key = "prefixSection",
- name = "Prefix Section",
- path = "powerBar",
- rows = {
- { id = "enabled", type = "checkbox", path = "enabled", name = "Enabled" },
- },
- },
- },
- })
-
- assert.is_true(addonNS.Addon.db.profile.powerBar.enabled)
- end)
-
- it("root:Register condition=false skips entry", function()
- local headerCreated = false
- local origHeader = CreateSettingsListSectionHeaderInitializer
- _G.CreateSettingsListSectionHeaderInitializer = function(text)
- if text == "Should Not Appear" then
- headerCreated = true
- end
- return origHeader(text)
- end
-
- local SB2 = createSB2("COND1", "CondTest")
-
- SB2.GetRoot("CondTest"):Register({
- sections = {
- {
- key = "condSection",
- name = "Cond Section",
- path = "global",
- rows = {
- {
- id = "skipped",
- type = "header",
- name = "Should Not Appear",
- condition = function()
- return false
- end,
- },
- { id = "shown", type = "header", name = "Should Appear" },
- },
- },
- },
- })
-
- _G.CreateSettingsListSectionHeaderInitializer = origHeader
- assert.is_false(headerCreated)
- end)
-
- it("root:Register condition=true includes entry", function()
- local headerCreated = false
- local origHeader = CreateSettingsListSectionHeaderInitializer
- _G.CreateSettingsListSectionHeaderInitializer = function(text)
- if text == "Conditional Header" then
- headerCreated = true
- end
- return origHeader(text)
- end
-
- local SB2 = createSB2("COND2", "CondTest2")
-
- SB2.GetRoot("CondTest2"):Register({
- sections = {
- {
- key = "condSection2",
- name = "Cond Section 2",
- path = "global",
- rows = {
- {
- id = "shown",
- type = "header",
- name = "Conditional Header",
- condition = function()
- return true
- end,
- },
- },
- },
- },
- })
-
- _G.CreateSettingsListSectionHeaderInitializer = origHeader
- assert.is_true(headerCreated)
- end)
-
- it("root:Register passes hidden predicates to header initializers", function()
- local capturedHeader
- local origHeader = CreateSettingsListSectionHeaderInitializer
- _G.CreateSettingsListSectionHeaderInitializer = function(text)
- capturedHeader = origHeader(text)
- return capturedHeader
- end
-
- local SB2 = createSB2("COND3", "CondTest3")
-
- SB2.GetRoot("CondTest3"):Register({
- sections = {
- {
- key = "condSection3",
- name = "Cond Section 3",
- path = "global",
- rows = {
- {
- id = "shown",
- type = "header",
- name = "Conditional Header",
- hidden = function()
- return true
- end,
- },
- },
- },
- },
- })
-
- _G.CreateSettingsListSectionHeaderInitializer = origHeader
- assert.is_not_nil(capturedHeader)
- assert.are.equal(1, #(capturedHeader._shownPredicates or {}))
- assert.is_false(capturedHeader._shownPredicates[1]())
- end)
-
- it("root:Register root pages stay on the root category", function()
- local SB2 = createSB2("ROOT1", "RootTest")
- local root = SB2.GetRoot("RootTest")
-
- root:Register({
- page = {
- key = "rootSection",
- path = "global",
- rows = {
- { id = "mounted", type = "checkbox", path = "hideWhenMounted", name = "Hide" },
- },
- },
- })
-
- assert.are.equal("RootTest", root:GetPage("rootSection"):GetID())
- end)
-
- it("root:Register canvas rows embed a canvas frame", function()
- local SB2 = createSB2("CANVAS1", "CanvasTest")
-
- local canvasFrame = {
- GetHeight = function()
- return 200
- end,
- }
-
- local embeddedCanvas, embeddedHeight
- local origEmbed = SB2.EmbedCanvas
- SB2.EmbedCanvas = function(canvas, height, spec)
- embeddedCanvas = canvas
- embeddedHeight = height
- return origEmbed(canvas, height, spec)
- end
-
- SB2.GetRoot("CanvasTest"):Register({
- sections = {
- {
- key = "canvasSection",
- name = "Canvas Section",
- path = "global",
- rows = {
- { id = "myCanvas", type = "canvas", canvas = canvasFrame, height = 400 },
- },
- },
- },
- })
-
- assert.are.equal(canvasFrame, embeddedCanvas)
- assert.are.equal(400, embeddedHeight)
- end)
-
- it("CanvasLayout supports configurable defaults and per-layout overrides", function()
- local originalCreateFrame = _G.CreateFrame
- _G.CreateFrame = function(_, _, _, template)
- local frame = TestHelpers.makeFrame({ height = 0, width = 0 })
- frame._template = template
- frame.SetSize = function(self, width, height)
- self:SetWidth(width)
- self:SetHeight(height)
- end
- frame.SetText = function(self, text)
- self._text = text
- end
- frame.CreateFontString = function()
- local fontString = TestHelpers.makeFrame()
- fontString.SetText = function(self, text)
- self._text = text
- end
- fontString.SetFontObject = function() end
- fontString.SetWordWrap = function() end
- fontString.SetJustifyH = function() end
- fontString.SetJustifyV = function() end
- return fontString
- end
- return frame
- end
-
- local originalDefaults = TestHelpers.deepClone(SB.SetCanvasLayoutDefaults())
- SB.SetCanvasLayoutDefaults({ elementHeight = 30 })
-
- local defaultLayout = SB.CreateCanvasLayout("Canvas Defaults")
- local defaultRow = defaultLayout:AddDescription("Uses updated defaults")
- assert.are.equal(30, defaultRow:GetHeight())
-
- local customLayout = SB.CreateCanvasLayout("Canvas Custom")
- SB.ConfigureCanvasLayout(customLayout, {
- elementHeight = 42,
- labelX = 20,
- buttonCenterX = -10,
- buttonWidth = 180,
- })
-
- local row, button = customLayout:AddButton("Action", "Run")
-
- assert.are.equal(42, row:GetHeight())
- TestHelpers.assertAnchor(row._label, 1, "LEFT", 20, 0, 0, 0)
- TestHelpers.assertAnchor(button, 1, "LEFT", row, "CENTER", -10, 0)
- assert.are.equal(180, button:GetWidth())
-
- SB.SetCanvasLayoutDefaults(originalDefaults)
- _G.CreateFrame = originalCreateFrame
- end)
-
- it("onSet receives setting as second parameter", function()
- local receivedSetting
- local receivedValue
-
- local _, setting = SB.Checkbox({
- path = "global.hideWhenMounted",
- name = "Test onSet",
- onSet = function(value, s)
- receivedValue = value
- receivedSetting = s
- end,
- })
-
- -- Trigger the setter via SetValue which calls the proxy setter → postSet → onSet
- setting:SetValue(false)
- assert.are.equal(false, receivedValue)
- assert.are.equal(setting, receivedSetting)
- end)
-
- -- PathAdapter
- describe("PathAdapter", function()
- it("resolve returns get/set/default for nested path", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local pa = LSB.PathAdapter({
- getStore = function()
- return addonNS.Addon.db.profile
- end,
- getDefaults = function()
- return addonNS.Addon.db.defaults.profile
- end,
- })
-
- local binding = pa:resolve("global.hideWhenMounted")
- assert.is_function(binding.get)
- assert.is_function(binding.set)
- assert.are.equal(true, binding.default)
- assert.are.equal(true, binding.get())
-
- binding.set(false)
- assert.are.equal(false, addonNS.Addon.db.profile.global.hideWhenMounted)
- end)
-
- it("read returns nested value", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local pa = LSB.PathAdapter({
- getStore = function()
- return addonNS.Addon.db.profile
- end,
- getDefaults = function()
- return addonNS.Addon.db.defaults.profile
- end,
- })
-
- assert.are.equal(5, pa:read("global.value"))
- end)
-
- it("falls back to nil when defaults table missing", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local pa = LSB.PathAdapter({
- getStore = function()
- return addonNS.Addon.db.profile
- end,
- getDefaults = function()
- return nil
- end,
- })
-
- local binding = pa:resolve("global.hideWhenMounted")
- assert.is_nil(binding.default)
- end)
- end)
-
- -- Handler mode
- describe("handler mode", function()
- it("Checkbox with get/set/key works without pathAdapter", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({
- varPrefix = "Handler",
- onChanged = function() end,
- })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "handlerSection",
- name = "HandlerSection",
- rows = {},
- }, "HandlerTest")
- SBH._currentSubcategory = page._category
-
- local store = { myVal = true }
- local _, setting = SBH.Checkbox({
- get = function()
- return store.myVal
- end,
- set = function(v)
- store.myVal = v
- end,
- key = "myVal",
- default = true,
- name = "Handler Checkbox",
- })
-
- assert.are.equal(true, setting:GetValue())
- setting:SetValue(false)
- assert.are.equal(false, store.myVal)
- end)
-
- it("Slider with get/set/key and transforms", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({
- varPrefix = "Handler",
- onChanged = function() end,
- })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "handlerSection2",
- name = "HandlerSection2",
- rows = {},
- }, "HandlerTest2")
- SBH._currentSubcategory = page._category
-
- local store = { scale = 0.75 }
- local _, setting = SBH.Slider({
- get = function()
- return store.scale
- end,
- set = function(v)
- store.scale = v
- end,
- key = "scale",
- default = 1.0,
- name = "Handler Slider",
- min = 0,
- max = 2,
- step = 0.01,
- getTransform = function(v)
- return v * 100
- end,
- setTransform = function(v)
- return v / 100
- end,
- })
-
- assert.are.equal(75, setting:GetValue())
- setting:SetValue(50)
- assert.are.equal(0.5, store.scale)
- end)
-
- it("errors when spec has both path and get", function()
- assert.has.errors(function()
- SB.Checkbox({
- path = "global.hideWhenMounted",
- get = function()
- return true
- end,
- set = function() end,
- key = "x",
- name = "Bad Spec",
- })
- end)
- end)
-
- it("errors when handler mode missing set", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({ varPrefix = "H", onChanged = function() end })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "hErrS",
- name = "HErrS",
- rows = {},
- }, "HErr")
- SBH._currentSubcategory = page._category
-
- assert.has.errors(function()
- SBH.Checkbox({
- get = function()
- return true
- end,
- key = "x",
- name = "Missing Set",
- })
- end)
- end)
-
- it("errors when handler mode missing key", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({ varPrefix = "H2", onChanged = function() end })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "hErrS2",
- name = "HErrS2",
- rows = {},
- }, "HErr2")
- SBH._currentSubcategory = page._category
-
- assert.has.errors(function()
- SBH.Checkbox({
- get = function()
- return true
- end,
- set = function() end,
- name = "Missing Key",
- })
- end)
- end)
-
- it("path mode errors without pathAdapter", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({ varPrefix = "NP", onChanged = function() end })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "noPathS",
- name = "NoPathS",
- rows = {},
- }, "NoPath")
- SBH._currentSubcategory = page._category
-
- assert.has.errors(function()
- SBH.Checkbox({
- path = "some.path",
- name = "No Adapter",
- })
- end)
- end)
-
- it("Control dispatches handler-mode checkbox", function()
- local LSB = LibStub("LibSettingsBuilder-1.0")
- local SBH = LSB:New({ varPrefix = "Disp", onChanged = function() end })
- local _, page = setCurrentCategoryFromSection(SBH, {
- key = "dispSect",
- name = "DispSect",
- rows = {},
- }, "DispTest")
- SBH._currentSubcategory = page._category
-
- local store = { flag = false }
- local _, setting = SBH.Control({
- type = "checkbox",
- get = function()
- return store.flag
- end,
- set = function(v)
- store.flag = v
- end,
- key = "flag",
- default = false,
- name = "Dispatched Handler",
- })
-
- assert.are.equal(false, setting:GetValue())
- setting:SetValue(true)
- assert.are.equal(true, store.flag)
- end)
- end)
-
- describe("slider inline edit hook", function()
- it("rebinds the edit box to the current slider setting when frames are reused", function()
- local hooks = select(1, loadLibraryWithHookStubs())
- local initHook = hooks[_G.SettingsSliderControlMixin].Init
-
- local firstValue = 12
- local secondValue = 33
- local firstSetting = {
- GetValue = function()
- return firstValue
- end,
- SetValue = function(_, value)
- firstValue = value
- end,
- }
- local secondSetting = {
- GetValue = function()
- return secondValue
- end,
- SetValue = function(_, value)
- secondValue = value
- end,
- }
-
- local firstLabel = createScriptableFrame()
- firstLabel.IsObjectType = function(_, objectType)
- return objectType == "FontString"
- end
-
- local sliderWithSteppers = createScriptableFrame()
- sliderWithSteppers.Slider = {
- GetMinMaxValues = function()
- return 0, 100
- end,
- GetValueStep = function()
- return 5
- end,
- }
- sliderWithSteppers.RightText = firstLabel
- sliderWithSteppers.GetRegions = function()
- return firstLabel
- end
-
- local control = {
- SliderWithSteppers = sliderWithSteppers,
- }
-
- initHook(control, {
- GetSetting = function()
- return firstSetting
- end,
- })
-
- control._lsbValueButton:GetScript("OnClick")()
- assert.are.equal("12", control._lsbEditBox:GetText())
-
- local secondLabel = createScriptableFrame()
- secondLabel.IsObjectType = function(_, objectType)
- return objectType == "FontString"
- end
- sliderWithSteppers.RightText = secondLabel
- sliderWithSteppers.GetRegions = function()
- return secondLabel
- end
-
- initHook(control, {
- GetSetting = function()
- return secondSetting
- end,
- })
-
- control._lsbValueButton:GetScript("OnClick")()
- assert.are.equal("33", control._lsbEditBox:GetText())
-
- control._lsbEditBox:SetText("27")
- control._lsbEditBox:GetScript("OnEnterPressed")()
-
- assert.are.equal(25, secondValue)
- assert.are.equal(12, firstValue)
- assert.is_true(secondLabel:IsShown())
- assert.is_false(control._lsbEditBox._focused)
- end)
- end)
-
- describe("page lifecycle onShow/onHide", function()
- local LSB
-
- before_each(function()
- createSettingsPanelMock()
-
- TestHelpers.SetupLibStub()
- TestHelpers.SetupSettingsStubs()
-
- _G.hooksecurefunc = function(tbl, method, hook)
- if type(tbl) == "table" and type(method) == "string" and type(hook) == "function" then
- local orig = tbl[method]
- if type(orig) == "function" then
- tbl[method] = function(...)
- orig(...)
- hook(...)
- end
- end
- end
- end
-
- _G.SettingsListElementMixin = {}
- _G.SettingsDropdownControlMixin = {}
- _G.SettingsSliderControlMixin = {}
- _G.CreateFrame = function()
- return createScriptableFrame()
- end
-
- TestHelpers.LoadLibSettingsBuilder()
- LSB = LibStub("LibSettingsBuilder-1.0")
- end)
-
- local function makeSB(prefix)
- return LSB:New({
- pathAdapter = LSB.PathAdapter({
- getStore = function() return addonNS.Addon.db.profile end,
- getDefaults = function() return addonNS.Addon.db.defaults.profile end,
- getNestedValue = addonNS.OptionUtil.GetNestedValue,
- setNestedValue = addonNS.OptionUtil.SetNestedValue,
- }),
- varPrefix = prefix or "T",
- onChanged = function() end,
- })
- end
-
- local function registerLifecycleSection(sb, opts)
- local root = sb.GetRoot(opts.rootName or "Lifecycle")
- local key = opts.key or "page1"
-
- root:Register({
- sections = {
- {
- key = key,
- name = opts.name or "Page1",
- onShow = opts.onShow,
- onHide = opts.onHide,
- rows = opts.rows or {},
- },
- },
- })
-
- local page = root:GetSection(key):GetPage("main")
- return root, page, page._category
- end
-
- it("stores onShow/onHide callbacks when provided declaratively", function()
- local sb = makeSB()
- local _, _, cat = registerLifecycleSection(sb, {
- onShow = function() end,
- onHide = function() end,
- })
-
- assert.is_table(LSB._pageLifecycleCallbacks[cat])
- assert.is_function(LSB._pageLifecycleCallbacks[cat].onShow)
- assert.is_function(LSB._pageLifecycleCallbacks[cat].onHide)
- end)
-
- --- Simulates WoW's sidebar navigation: SetCurrentCategory then DisplayCategory.
- local function navigateTo(cat)
- SettingsPanel:SetCurrentCategory(cat)
- SettingsPanel:DisplayCategory(cat)
- end
-
- it("fires onShow when DisplayCategory is called with a tracked category", function()
- local sb = makeSB()
- local showCount = 0
- local _, _, cat = registerLifecycleSection(sb, {
- onShow = function() showCount = showCount + 1 end,
- })
-
- navigateTo(cat)
- assert.are.equal(1, showCount)
- end)
-
- it("fires onHide when switching away from a tracked category", function()
- local sb = makeSB()
- local hideCount = 0
- local _, _, cat = registerLifecycleSection(sb, {
- onHide = function() hideCount = hideCount + 1 end,
- })
-
- local other = { _name = "Other" }
- navigateTo(cat)
- navigateTo(other)
- assert.are.equal(1, hideCount)
- end)
-
- it("fires onHide when SettingsPanel is hidden", function()
- local sb = makeSB()
- local hideCount = 0
- local _, _, cat = registerLifecycleSection(sb, {
- onHide = function() hideCount = hideCount + 1 end,
- })
-
- navigateTo(cat)
- SettingsPanel._fireScript("OnHide")
- assert.are.equal(1, hideCount)
- end)
-
- it("does not fire duplicate onShow when same category re-selected", function()
- local sb = makeSB()
- local showCount = 0
- local _, _, cat = registerLifecycleSection(sb, {
- onShow = function() showCount = showCount + 1 end,
- })
-
- navigateTo(cat)
- navigateTo(cat)
- assert.are.equal(1, showCount)
- end)
-
- it("does not fire callbacks for categories without lifecycle hooks", function()
- local sb = makeSB()
- local _, _, untracked = registerLifecycleSection(sb, {
- key = "plain",
- name = "Plain",
- })
-
- -- Should not error
- navigateTo(untracked)
- end)
-
- it("clears active category on panel hide so next open fires onShow", function()
- local sb = makeSB()
- local showCount = 0
- local _, _, cat = registerLifecycleSection(sb, {
- onShow = function() showCount = showCount + 1 end,
- })
-
- navigateTo(cat)
- SettingsPanel._fireScript("OnHide")
- navigateTo(cat)
- assert.are.equal(2, showCount)
- end)
-
- it("defers hook installation when SettingsPanel is not yet available", function()
- -- Remove SettingsPanel before loading library
- _G.SettingsPanel = nil
-
- TestHelpers.SetupLibStub()
- TestHelpers.SetupSettingsStubs()
- _G.hooksecurefunc = function(tbl, method, hook)
- if type(tbl) == "table" and type(method) == "string" and type(hook) == "function" then
- local orig = tbl[method]
- if type(orig) == "function" then
- tbl[method] = function(...)
- orig(...)
- hook(...)
- end
- end
- end
- end
- _G.SettingsListElementMixin = {}
- _G.SettingsDropdownControlMixin = {}
- _G.SettingsSliderControlMixin = {}
-
- local deferFrame
- _G.CreateFrame = function()
- deferFrame = createScriptableFrame()
- return deferFrame
- end
-
- TestHelpers.LoadLibSettingsBuilder()
- local lsb = LibStub("LibSettingsBuilder-1.0")
-
- local sb = lsb:New({
- pathAdapter = lsb.PathAdapter({
- getStore = function() return addonNS.Addon.db.profile end,
- getDefaults = function() return addonNS.Addon.db.defaults.profile end,
- getNestedValue = addonNS.OptionUtil.GetNestedValue,
- setNestedValue = addonNS.OptionUtil.SetNestedValue,
- }),
- varPrefix = "D",
- onChanged = function() end,
- })
- local root = sb.GetRoot("Deferred")
-
- local showCount = 0
- root:Register({
- sections = {
- {
- key = "page1",
- name = "Page1",
- onShow = function() showCount = showCount + 1 end,
- rows = {},
- },
- },
- })
-
- -- Hooks not yet installed — deferred frame should exist
- assert.is_table(deferFrame)
- assert.is_false(lsb._pageLifecycleHooked)
-
- -- Simulate Blizzard_Settings loading
- createSettingsPanelMock()
- deferFrame:GetScript("OnEvent")(deferFrame, "ADDON_LOADED", "Blizzard_Settings")
-
- assert.is_true(lsb._pageLifecycleHooked)
-
- -- Hooks should now work
- local cat = root:GetSection("page1"):GetPage("main")._category
- SettingsPanel:SetCurrentCategory(cat)
- SettingsPanel:DisplayCategory(cat)
- assert.are.equal(1, showCount)
- end)
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "LibStub",
+ "Settings",
+ "CreateFrame",
+ "hooksecurefunc",
+ "SettingsDropdownControlMixin",
+ "SettingsSliderControlMixin",
+ "SettingsListElementMixin",
+ })
end)
- ---------------------------------------------------------------------------
- -- SB.Custom integration: template, setting, and InitFrame pipeline
- ---------------------------------------------------------------------------
- describe("Dynamic layout rows", function()
- it("PageActions accepts action buttons through spec tables", function()
- local init = SB.PageActions({
- name = "TestSection",
- actions = {
- { text = "Defaults", width = 100 },
- },
- })
-
- assert.are.equal(SB.SUBHEADER_TEMPLATE, init._template)
- assert.are.equal("TestSection", init.data.name)
- assert.are.equal("Defaults", init.data.actions[1].text)
- end)
-
- it("PageActions tooltips use the current GameTooltip SetText signature", function()
- TestHelpers.SetupGameTooltipStub()
-
- local init = SB.PageActions({
- name = "TestSection",
- actions = {
- { text = "Add", tooltip = "Create entry" },
- },
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.CreateFontString = function()
- return createScriptableFrame()
- end
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
- assert.has_no.errors(function()
- init:InitFrame(frame)
- end)
+ before_each(function()
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ TestHelpers.LoadLibSettingsBuilder()
+ end)
- local button = assert(frame._lsbHeaderActionButtons[1])
- assert.has_no.errors(function()
- button:GetScript("OnEnter")(button)
- end)
+ local function createBuilder(config)
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local profile = {
+ general = {
+ enabled = true,
+ height = nil,
+ },
+ }
+ local defaults = {
+ general = {
+ enabled = false,
+ height = 12,
+ },
+ }
- assert.are.equal("Create entry", _G.GameTooltip._title)
- assert.are.same({ r = 1, g = 1, b = 1, a = 1 }, _G.GameTooltip._titleColor)
- assert.are.equal(1, _G.GameTooltip._titleAlpha)
- assert.is_true(_G.GameTooltip._titleWrap)
- end)
+ return lsb.New({
+ name = "Builder Spec",
+ store = function()
+ return profile
+ end,
+ defaults = function()
+ return defaults
+ end,
+ onChanged = function() end,
+ page = config and config.page or nil,
+ sections = config and config.sections or nil,
+ }), profile, defaults
+ end
- it("PageActions attach buttons to the page title row without rendering a duplicate header", function()
- local init = SB.PageActions({
- name = "TestSection",
- category = SB._currentSubcategory,
- actions = {
- { text = "Defaults", width = 100 },
+ it("registers root and section pages through LSB.New", function()
+ local sb = createBuilder({
+ page = {
+ key = "about",
+ rows = {
+ { type = "info", name = "Version", value = "1.0" },
},
- })
-
- assert.is_table(init)
- assert.is_true(init.data.hideTitle)
- assert.is_true(init.data.attachToCategoryHeader)
- assert.are.equal(1, init:GetExtent())
- end)
-
- it("InfoRow with function-backed value registers as refreshable", function()
- local category = SB._currentSubcategory
- SB.InfoRow({
- name = "Dynamic",
- value = function()
- return "value"
- end,
- })
-
- assert.is_table(SB._categoryRefreshables[category])
- assert.are.equal(1, #SB._categoryRefreshables[category])
- end)
-
- it("root:Register dispatches list rows through SB.List", function()
- local called
- local originalList = SB.List
- SB.List = function(spec)
- called = spec
- return { _type = "list" }
- end
-
- SB.GetRoot("TestAddon"):Register({
- sections = {
- {
- key = "collectionPage",
- name = "Collection Page",
- rows = {
- {
- id = "items",
- type = "list",
- height = 200,
- variant = "swatch",
- items = function()
- return {}
- end,
+ },
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { type = "checkbox", path = "general.enabled", name = "Enabled" },
},
},
},
},
- })
-
- SB.List = originalList
-
- assert.is_table(called)
- assert.are.equal(200, called.height)
- assert.are.equal("swatch", called.variant)
- end)
+ },
+ })
- it("page:Refresh reevaluates visible frames and dynamic refreshables", function()
- local frames = createSettingsPanelMock()
- local page = SB.GetRoot("TestAddon"):GetSection("testSection"):GetPage("main")
- local category = page._category
- local refreshed = 0
- local frame = createScriptableFrame()
- frame.EvaluateState = function(self)
- self._evaluated = true
- end
+ local rootPage = assert(sb:GetRootPage())
+ local generalPage = assert(sb:GetPage("general", "main"))
- frames[1] = frame
- SettingsPanel:SetCurrentCategory(category)
+ assert.are.equal("Builder Spec", rootPage:GetId())
+ assert.are.equal("Builder Spec.General", generalPage:GetId())
+ assert.are.equal("general", assert(sb:GetSection("general")).key)
+ assert.is_true(sb:HasCategory(rootPage._category))
+ assert.is_true(sb:HasCategory(generalPage._category))
+ end)
- SB._categoryRefreshables[category] = {
+ it("returns nil for missing section-page lookups", function()
+ local sb = createBuilder({
+ sections = {
{
- _lsbActiveFrame = frame,
- _lsbRefreshFrame = function(activeFrame)
- refreshed = refreshed + 1
- activeFrame._refreshed = true
- end,
- },
- }
-
- page:Refresh()
-
- assert.are.equal(1, refreshed)
- assert.is_true(frame._evaluated)
- assert.is_true(frame._refreshed)
- end)
-
- it("List shows cached scroll widgets when a settings row is reused", function()
- local originalCreateFrame = _G.CreateFrame
- local originalCreateDataProvider = _G.CreateDataProvider
- local originalCreateView = _G.CreateScrollBoxListLinearView
- local originalScrollUtil = _G.ScrollUtil
-
- _G.CreateFrame = function(_, _, _, template)
- local frame = createScriptableFrame()
- frame._template = template
- frame.SetDataProvider = function(self, provider)
- self._dataProvider = provider
- end
- return frame
- end
- _G.CreateDataProvider = function()
- return {
- Flush = function() end,
- Insert = function() end,
- }
- end
- _G.CreateScrollBoxListLinearView = function()
- return {
- SetElementExtent = function() end,
- SetElementInitializer = function() end,
- }
- end
- _G.ScrollUtil = {
- InitScrollBoxListWithScrollBar = function() end,
- }
-
- local init = SB.List({
- height = 80,
- variant = "swatch",
- items = function()
- return {}
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
- local scrollBox = assert(frame._lsbCollectionScrollBox)
- local scrollBar = assert(frame._lsbCollectionScrollBar)
- scrollBox:Hide()
- scrollBar:Hide()
-
- init:InitFrame(frame)
-
- assert.is_true(scrollBox:IsShown())
- assert.is_true(scrollBar:IsShown())
-
- _G.CreateFrame = originalCreateFrame
- _G.CreateDataProvider = originalCreateDataProvider
- _G.CreateScrollBoxListLinearView = originalCreateView
- _G.ScrollUtil = originalScrollUtil
- end)
-
- it("swatch list rows open the color callback from the swatch click script", function()
- local originalCreateFrame = _G.CreateFrame
- local originalCreateDataProvider = _G.CreateDataProvider
- local originalCreateView = _G.CreateScrollBoxListLinearView
- local originalScrollUtil = _G.ScrollUtil
- local clicked = 0
- local entered = 0
-
- _G.CreateFrame = function(frameType, name, parent, template)
- local frame = originalCreateFrame(frameType, name, parent, template)
- frame.SetDataProvider = function(self, provider)
- self._dataProvider = provider
- end
- frame.SetColorRGB = function(self, r, g, b)
- self._color = { r, g, b }
- end
- return frame
- end
- _G.CreateDataProvider = function()
- return {
- Flush = function() end,
- Insert = function() end,
- }
- end
- _G.CreateScrollBoxListLinearView = function()
- return {
- SetElementExtent = function() end,
- SetElementInitializer = function(self, _, fn)
- self._initFn = fn
- end,
- }
- end
- _G.ScrollUtil = {
- InitScrollBoxListWithScrollBar = function() end,
- }
-
- local init = SB.List({
- height = 80,
- variant = "swatch",
- items = function()
- return {
+ key = "general",
+ name = "General",
+ pages = {
{
- label = "Spell",
- icon = 1234,
- onEnter = function()
- entered = entered + 1
- end,
- color = {
- value = { r = 0.1, g = 0.2, b = 0.3 },
- onClick = function()
- clicked = clicked + 1
- end,
+ key = "main",
+ rows = {
+ { type = "info", name = "Version", value = "1.0" },
},
},
- }
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
-
- local row = createScriptableFrame()
- row.CreateTexture = function()
- local texture = createScriptableFrame()
- texture.SetTexture = function(self, value)
- self._texture = value
- end
- texture.GetTexture = function(self)
- return self._texture
- end
- return texture
- end
- row.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- return fontString
- end
- frame._lsbCollectionView._initFn(row, {
- preset = "swatch",
- item = init.data.items()[1],
- })
-
- local point, relativeTo, relativePoint, x, y = row._swatch:GetPoint(1)
- assert.is_true(row._mouseEnabled)
- assert.are.equal("LEFT", point)
- assert.are.equal(row, relativeTo)
- assert.are.equal("CENTER", relativePoint)
- assert.are.equal(-73, x)
- assert.are.equal(0, y)
-
- row:GetScript("OnEnter")(row)
- assert.are.equal(1, entered)
-
- assert.is_nil(row:GetScript("OnMouseUp"))
- row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
-
- assert.are.equal(1, clicked)
-
- _G.CreateFrame = originalCreateFrame
- _G.CreateDataProvider = originalCreateDataProvider
- _G.CreateScrollBoxListLinearView = originalCreateView
- _G.ScrollUtil = originalScrollUtil
- end)
-
- it("swatch list rows rebind the swatch click handler when a recycled row is reused", function()
- local originalCreateFrame = _G.CreateFrame
- local originalCreateDataProvider = _G.CreateDataProvider
- local originalCreateView = _G.CreateScrollBoxListLinearView
- local originalScrollUtil = _G.ScrollUtil
- local firstClicks = 0
- local secondClicks = 0
-
- _G.CreateFrame = function(frameType, name, parent, template)
- local frame = originalCreateFrame(frameType, name, parent, template)
- frame.SetDataProvider = function(self, provider)
- self._dataProvider = provider
- end
- frame.SetColorRGB = function(self, r, g, b)
- self._color = { r, g, b }
- end
- return frame
- end
- _G.CreateDataProvider = function()
- return {
- Flush = function() end,
- Insert = function() end,
- }
- end
- _G.CreateScrollBoxListLinearView = function()
- return {
- SetElementExtent = function() end,
- SetElementInitializer = function(self, _, fn)
- self._initFn = fn
- end,
- }
- end
- _G.ScrollUtil = {
- InitScrollBoxListWithScrollBar = function() end,
- }
-
- local init = SB.List({
- height = 80,
- variant = "swatch",
- items = function()
- return {}
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
-
- local row = createScriptableFrame()
- row.CreateTexture = function()
- local texture = createScriptableFrame()
- texture.SetTexture = function(self, value)
- self._texture = value
- end
- texture.GetTexture = function(self)
- return self._texture
- end
- return texture
- end
- row.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- return fontString
- end
-
- frame._lsbCollectionView._initFn(row, {
- preset = "swatch",
- item = {
- label = "First",
- color = {
- value = { r = 0.1, g = 0.2, b = 0.3 },
- onClick = function()
- firstClicks = firstClicks + 1
- end,
- },
- },
- })
- row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
-
- frame._lsbCollectionView._initFn(row, {
- preset = "swatch",
- item = {
- label = "Second",
- color = {
- value = { r = 0.4, g = 0.5, b = 0.6 },
- onClick = function()
- secondClicks = secondClicks + 1
- end,
},
},
- })
- row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
-
- assert.are.equal(1, firstClicks)
- assert.are.equal(1, secondClicks)
-
- _G.CreateFrame = originalCreateFrame
- _G.CreateDataProvider = originalCreateDataProvider
- _G.CreateScrollBoxListLinearView = originalCreateView
- _G.ScrollUtil = originalScrollUtil
- end)
-
- it("editor list rows open the color callback from the swatch click script", function()
- local originalCreateFrame = _G.CreateFrame
- local originalCreateDataProvider = _G.CreateDataProvider
- local originalCreateView = _G.CreateScrollBoxListLinearView
- local originalScrollUtil = _G.ScrollUtil
- local clicked = 0
-
- _G.CreateFrame = function(frameType, name, parent, template)
- local frame = originalCreateFrame(frameType, name, parent, template)
- frame.SetDataProvider = function(self, provider)
- self._dataProvider = provider
- end
- frame.SetColorRGB = function(self, r, g, b)
- self._color = { r, g, b }
- end
- return frame
- end
- _G.CreateDataProvider = function()
- return {
- Flush = function() end,
- Insert = function() end,
- }
- end
- _G.CreateScrollBoxListLinearView = function()
- return {
- SetElementExtent = function() end,
- SetElementInitializer = function(self, _, fn)
- self._initFn = fn
- end,
- }
- end
- _G.ScrollUtil = {
- InitScrollBoxListWithScrollBar = function() end,
- }
-
- local row = createScriptableFrame()
- row.CreateTexture = function()
- return createScriptableFrame()
- end
- row.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- return fontString
- end
-
- local item = {
- label = "Tick",
- fields = {},
- color = {
- value = { r = 0.1, g = 0.2, b = 0.3 },
- onClick = function()
- clicked = clicked + 1
- end,
- },
- remove = {
- onClick = function() end,
- },
- }
-
- local init = SB.List({
- height = 80,
- variant = "editor",
- items = function()
- return { item }
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
- frame._lsbCollectionView._initFn(row, {
- preset = "editor",
- item = item,
- })
-
- row._swatch:GetScript("OnClick")(row._swatch, "LeftButton")
-
- assert.are.equal(1, clicked)
-
- _G.CreateFrame = originalCreateFrame
- _G.CreateDataProvider = originalCreateDataProvider
- _G.CreateScrollBoxListLinearView = originalCreateView
- _G.ScrollUtil = originalScrollUtil
- end)
-
- it("SectionList action rows apply icon button textures, click registration, and spacing", function()
- local originalCreateFrame = _G.CreateFrame
-
- local function attachButtonTextureState(frame, key)
- local texture = createScriptableFrame()
- texture.ClearAllPoints = function() end
- texture.SetAllPoints = function(self, owner)
- self._allPoints = owner
- end
- texture.SetAlpha = function(self, alpha)
- self._alpha = alpha
- end
- texture.GetTexture = function(self)
- return self._texture
- end
-
- frame["Set" .. key .. "Texture"] = function(self, value)
- self["_" .. key:lower() .. "TextureValue"] = value
- texture._texture = value
- end
- frame["Get" .. key .. "Texture"] = function()
- return texture
- end
- end
+ },
+ })
- _G.CreateFrame = function(...)
- local frame = originalCreateFrame(...)
- attachButtonTextureState(frame, "Normal")
- attachButtonTextureState(frame, "Pushed")
- attachButtonTextureState(frame, "Disabled")
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- return fontString
- end
- frame.CreateTexture = function()
- local texture = createScriptableFrame()
- texture.SetTexture = function(self, value)
- self._texture = value
- end
- texture.GetTexture = function(self)
- return self._texture
- end
- return texture
- end
- frame.SetHighlightTexture = function(self, value, blendMode)
- self._highlightTextureValue = value
- self._highlightBlendMode = blendMode
- self._highlightTexture = self._highlightTexture or createScriptableFrame()
- self._highlightTexture.ClearAllPoints = function() end
- self._highlightTexture.SetAllPoints = function(texture, owner)
- texture._allPoints = owner
- end
- self._highlightTexture.SetAlpha = function(texture, alpha)
- texture._alpha = alpha
- end
- self._highlightTexture.GetTexture = function(texture)
- return texture._texture
- end
- self._highlightTexture._texture = value
- end
- frame.GetHighlightTexture = function(self)
- return self._highlightTexture
- end
- return frame
- end
+ assert.is_nil(sb:GetPage("general"))
+ assert.is_nil(sb:GetPage("missing", "main"))
+ assert.is_nil(sb:GetPage("general", "missing"))
+ end)
- local init = SB.SectionList({
- height = 120,
- sections = function()
- return {
+ it("registers root-bound composite rows from an empty path", function()
+ local sb, profile = createBuilder({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
{
- key = "icons",
- title = "Icons",
- items = {
- {
- label = "Spell",
- icon = 1234,
- actions = {
- up = {
- text = "^",
- width = 20,
- height = 20,
- enabled = false,
- buttonTextures = {
- normal = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
- pushed = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_down",
- },
- },
- down = {
- text = "v",
- width = 20,
- height = 20,
- enabled = true,
- buttonTextures = {
- normal = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_down_normal",
- pushed = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_down_down",
- },
- },
- },
- },
+ key = "main",
+ rows = {
+ { type = "heightOverride", path = "", disabled = false },
},
},
- }
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
-
- local row = assert(frame._lsbSectionRowPools.icons[1])
- local upButton = assert(row._buttons.up)
- local downButton = assert(row._buttons.down)
-
- assert.are.equal("", upButton:GetText())
- assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
- upButton._normalTextureValue
- )
- assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_down",
- upButton._pushedTextureValue
- )
- assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
- upButton._disabledTextureValue
- )
- assert.are.equal("Interface\\Buttons\\ButtonHilight-Square", upButton._highlightTextureValue)
- assert.are.equal("ADD", upButton._highlightBlendMode)
- assert.are.equal(0.25, upButton:GetHighlightTexture()._alpha)
- assert.are.equal(0.4, upButton:GetAlpha())
- assert.is_false(upButton._enabled)
- assert.are.same({ "LeftButtonDown" }, upButton._registeredClicks)
-
- local point, relativeTo, relativePoint, x, y = upButton:GetPoint(1)
- assert.are.equal("RIGHT", point)
- assert.are.equal(row, relativeTo)
- assert.are.equal("RIGHT", relativePoint)
- assert.are.equal(-2, x)
- assert.are.equal(0, y)
-
- point, relativeTo, relativePoint, x, y = downButton:GetPoint(1)
- assert.are.equal("RIGHT", point)
- assert.are.equal(upButton, relativeTo)
- assert.are.equal("LEFT", relativePoint)
- assert.are.equal(-2, x)
- assert.are.equal(0, y)
- assert.are.same({ "LeftButtonDown" }, downButton._registeredClicks)
-
- _G.CreateFrame = originalCreateFrame
- end)
-
- it("SectionList modeInput footers reevaluate dynamic fields in place while typing", function()
- local originalCreateFrame = _G.CreateFrame
- local state = {
- kind = "spell",
- idText = "",
- }
-
- _G.CreateFrame = function(...)
- local frame = originalCreateFrame(...)
- frame.CreateFontString = function()
- local fontString = createScriptableFrame()
- fontString.SetFontObject = function() end
- return fontString
- end
- frame.CreateTexture = function()
- local texture = createScriptableFrame()
- texture.SetTexture = function(self, value)
- self._texture = value
- end
- texture.GetTexture = function(self)
- return self._texture
- end
- return texture
- end
- return frame
- end
-
- local ok, err = pcall(function()
- local init = SB.SectionList({
- height = 120,
- sections = function()
- return {
- {
- key = "dynamic",
- title = "Dynamic",
- items = {},
- footer = {
- type = "modeInput",
- modeText = function()
- return state.kind == "spell" and "Spell" or "Item"
- end,
- inputText = function()
- return state.idText
- end,
- placeholder = function()
- return state.kind == "spell" and "Spell ID" or "Item ID"
- end,
- previewText = function()
- return state.idText ~= "" and (state.kind .. ":" .. state.idText) or ""
- end,
- submitEnabled = function()
- return state.idText ~= ""
- end,
- onTextChanged = function(text)
- state.idText = text
- end,
- onTabPressed = function()
- state.kind = state.kind == "spell" and "item" or "spell"
- return true
- end,
- },
- },
- }
- end,
- })
- local frame = createScriptableFrame()
- frame.Text = createScriptableFrame()
- frame.NewFeature = createScriptableFrame()
- frame.SetShown = function(self, shown)
- self._shown = shown
- end
-
- init:InitFrame(frame)
-
- local trailerRow = assert(frame._lsbSectionTrailerRows.dynamic)
- local editBox = assert(trailerRow._editBox)
- editBox:SetFocus()
- trailerRow._lsbHasFocus = true
- editBox:SetText("12345")
- editBox:GetScript("OnTextChanged")(editBox)
-
- assert.are.equal("12345", state.idText)
- assert.are.equal("spell:12345", trailerRow._previewLabel:GetText())
- assert.is_true(editBox._focused)
-
- editBox:GetScript("OnTabPressed")()
-
- assert.are.equal("Item", trailerRow._modeButton:GetText())
- assert.are.equal("Item ID", trailerRow._placeholder:GetText())
- assert.is_true(editBox._focused)
- assert.is_true(editBox._highlighted)
- end)
- _G.CreateFrame = originalCreateFrame
- if not ok then
- error(err, 0)
- end
- end)
- end)
-
- describe("Custom control integration", function()
- it("passes the actual template name to CreateElementInitializer", function()
- local capturedTemplate
- local settings = Settings
- local origCEI = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(template, data)
- capturedTemplate = template
- return origCEI(template, data)
- end)
-
- SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- rawset(settings, "CreateElementInitializer", origCEI)
- assert.are.equal("LibLSMSettingsWidgets_FontPickerTemplate", capturedTemplate)
- end)
-
- it("attaches the setting so InitFrame can retrieve it via GetSetting", function()
- local init, setting = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- assert.is_not_nil(init:GetSetting())
- assert.are.equal(setting, init:GetSetting())
- end)
-
- it("setting reads the current profile value", function()
- local _, setting = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- assert.are.equal("Global Font", setting:GetValue())
- end)
-
- it("setting writes back to the profile", function()
- local _, setting = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- setting:SetValue("NewFont")
- assert.are.equal("NewFont", addonNS.Addon.db.profile.global.font)
- end)
-
- it("initializer data contains name and tooltip", function()
- local init = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- tooltip = "Choose a font",
- })
-
- local data = init:GetData()
- assert.are.equal("Font Picker", data.name)
- assert.are.equal("Choose a font", data.tooltip)
- end)
-
- it("setting is retrievable so XML mixin Init can access it", function()
- local init, setting = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- -- In the real WoW path, the settings framework creates a frame
- -- from the XML template (which applies the mixin and fires OnLoad),
- -- then calls frame:Init(initializer). The mixin's Init calls
- -- initializer:GetSetting() to bind the dropdown. This test verifies
- -- that the setting is attached and accessible on the initializer —
- -- the critical contract that the XML mixin relies on.
- assert.is_not_nil(init:GetSetting())
- assert.are.equal(setting, init:GetSetting())
- assert.are.equal("Global Font", setting:GetValue())
- end)
-
- it("root:Register dispatches custom type through SB.Custom", function()
- local capturedTemplate
- local settings = Settings
- local origCEI = settings.CreateElementInitializer
- rawset(settings, "CreateElementInitializer", function(template, data)
- capturedTemplate = template
- return origCEI(template, data)
- end)
+ },
+ },
+ },
+ })
- SB.GetRoot("TestAddon"):Register({
- sections = {
+ local settings = TestHelpers.CollectSettings(function()
+ TestHelpers.RegisterSectionSpec(sb, {
+ key = "generalTwo",
+ name = "General Two",
+ path = "general",
+ pages = {
{
- key = "testCustomSection",
- name = "Test Custom Section",
- path = "global",
+ key = "main",
rows = {
- { id = "testHeader", type = "header", name = "Appearance" },
- {
- id = "fontPicker",
- type = "custom",
- path = "font",
- name = "Font",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- },
+ { type = "heightOverride", path = "", disabled = false },
},
},
},
})
-
- rawset(settings, "CreateElementInitializer", origCEI)
- assert.are.equal("LibLSMSettingsWidgets_FontPickerTemplate", capturedTemplate)
end)
- it("does not wrap or replace InitFrame on the initializer", function()
- local init = SB.Custom({
- path = "global.font",
- name = "Font Picker",
- template = "LibLSMSettingsWidgets_FontPickerTemplate",
- })
-
- -- A stock SettingsListElementInitializer from the stub has no
- -- InitFrame. If SB.Custom starts injecting one (e.g. for mixin
- -- injection), that's a regression — XML templates handle this.
- assert.is_nil(init.InitFrame)
- end)
+ assert.is_not_nil(settings["BS_generalTwo_height"] or settings["BS_general_height"])
+ assert.is_nil(profile.general.height)
end)
- describe("root declarative API", function()
- it("GetRoot is idempotent and rejects conflicting names", function()
- local sb = createSB2("ROOTAPI1", "Root API")
- local rootA = sb.GetRoot("Root API")
- local rootB = sb.GetRoot("Root API")
-
- assert.are.equal(rootA, rootB)
- assert.has_error(function()
- sb.GetRoot("Other Root")
- end)
- end)
-
- it("registers a root page on the root category and rejects a second root page", function()
- local sb = createSB2("ROOTAPI2", "Root API")
- local root = sb.GetRoot("Root API")
- root:Register({
- page = {
- key = "about",
- rows = {
- { type = "info", name = "Version", value = "1.0" },
- },
- },
- })
-
- assert.are.equal("Root API", root:GetPage("about"):GetID())
-
- assert.has_error(function()
- root:Register({
- page = {
- key = "second",
- rows = {
- { type = "info", name = "Other", value = "2.0" },
- },
- },
- })
- end)
- end)
-
- it("flattens single-page sections and preserves path-bound settings", function()
- local sb = createSB2("ROOTAPI3", "Root API")
- local root = sb.GetRoot("Root API")
- local captured = TestHelpers.CollectSettings(function()
- root:Register({
- sections = {
- {
- key = "general",
- name = "General",
- path = "global",
- rows = {
- {
- type = "checkbox",
- path = "hideWhenMounted",
- name = "Hide When Mounted",
- },
- },
- },
- },
- })
- end)
-
- local page = root:GetSection("general"):GetPage("main")
- local _, setting = next(captured)
-
- assert.are.equal("Root API.General", page:GetID())
- assert.is_true(setting:GetValue())
- setting:SetValue(false)
- assert.is_false(addonNS.Addon.db.profile.global.hideWhenMounted)
- end)
-
- it("nests multi-page sections and honors explicit nested display for single-page sections", function()
- local sb = createSB2("ROOTAPI4", "Root API")
- local root = sb.GetRoot("Root API")
- root:Register({
+ it("rejects deprecated desc fields at registration time", function()
+ local ok, err = pcall(function()
+ createBuilder({
sections = {
{
- key = "multi",
- name = "Multi",
+ key = "general",
+ name = "General",
pages = {
- { key = "first", name = "First", rows = {} },
- { key = "second", name = "Second", rows = {} },
+ {
+ key = "main",
+ rows = {
+ { type = "checkbox", path = "general.enabled", name = "Enabled", desc = "Old tooltip" },
+ },
+ },
},
},
- {
- key = "nested",
- name = "Nested",
- display = "nested",
- rows = {},
- },
},
})
-
- local second = root:GetSection("multi"):GetPage("second")
- local only = root:GetSection("nested"):GetPage("main")
-
- assert.are.equal("Root API.Multi.Second", second:GetID())
- assert.are.equal("Root API.Nested.Nested", only:GetID())
end)
- it("injects page as first arg to onClick callbacks in declarative rows", function()
- local sb = createSB2("ROOTAPI5", "Root API")
- local root = sb.GetRoot("Root API")
- local receivedPage
- root:Register({
+ assert.is_false(ok)
+ assert.is_truthy(tostring(err):find("deprecated field 'desc'", 1, true))
+ end)
+
+ it("rejects removed condition fields at registration time", function()
+ local ok, err = pcall(function()
+ createBuilder({
sections = {
{
- key = "clicks",
- name = "Clicks",
- rows = {
+ key = "general",
+ name = "General",
+ pages = {
{
- type = "button",
- name = "Test",
- buttonText = "Test",
- onClick = function(pg) receivedPage = pg end,
+ key = "main",
+ rows = {
+ {
+ type = "checkbox",
+ path = "general.enabled",
+ name = "Enabled",
+ condition = function()
+ return true
+ end,
+ },
+ },
},
},
},
},
})
-
- local page = root:GetSection("clicks"):GetPage("main")
-
- local layout = page._category:GetLayout()
- local inits = layout._initializers
- local buttonInit
- for i = #inits, 1, -1 do
- if inits[i]._type == "button" then
- buttonInit = inits[i]
- break
- end
- end
- assert.is_not_nil(buttonInit, "button initializer not found")
- buttonInit._onClick()
- assert.are.equal(page, receivedPage)
end)
- it("injects page as third arg to onSet callbacks in declarative rows", function()
- local sb = createSB2("ROOTAPI6", "Root API")
- local root = sb.GetRoot("Root API")
- local receivedArgs = {}
- local captured = TestHelpers.CollectSettings(function()
- root:Register({
- sections = {
+ assert.is_false(ok)
+ assert.is_truthy(tostring(err):find("removed field 'condition'", 1, true))
+ end)
+
+ it("keeps page handles limited to the v2 public surface", function()
+ local sb = createBuilder({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
{
- key = "onset",
- name = "OnSet",
- path = "global",
+ key = "main",
rows = {
- {
- type = "checkbox",
- path = "hideWhenMounted",
- name = "Hide When Mounted",
- onSet = function(value, _, pg)
- receivedArgs = { value = value, page = pg }
- end,
- },
+ { type = "info", name = "Version", value = "1.0" },
},
},
},
- })
- end)
-
- local page = root:GetSection("onset"):GetPage("main")
- local _, setting = next(captured)
+ },
+ },
+ })
+ local page = assert(sb:GetPage("general", "main"))
- setting:SetValue(false)
- assert.are.equal(false, receivedArgs.value)
- assert.are.equal(page, receivedArgs.page)
- end)
+ assert.is_function(page.GetId)
+ assert.is_function(page.Refresh)
+ assert.is_nil(page.GetID)
+ assert.is_nil(page.RegisterRows)
+ assert.is_nil(page.Checkbox)
+ assert.is_nil(page.List)
end)
end)
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index cce5b9b4..17f8e45a 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -62,51 +62,55 @@ describe("LibSettingsBuilder Collections", function()
TestHelpers.LoadLibSettingsBuilder()
end)
- it("creates first-class list and sectionList initializers after the split load", function()
+ it("creates first-class list and sectionList initializers from raw row specs", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
- local SB = lsb:New({
- pathAdapter = lsb.PathAdapter({
- getStore = function()
- return { root = {} }
- end,
- getDefaults = function()
- return { root = {} }
- end,
- }),
- varPrefix = "COLL",
+ local SB = lsb.New({
+ name = "Collections",
+ store = function()
+ return { root = {} }
+ end,
+ defaults = function()
+ return { root = {} }
+ end,
onChanged = function() end,
- })
- local root = SB.GetRoot("Collections")
- root:Register({
sections = {
{
key = "rows",
name = "Rows",
- rows = {},
+ pages = {
+ {
+ key = "main",
+ rows = {
+ {
+ id = "listRow",
+ type = "list",
+ height = 120,
+ items = function()
+ return {}
+ end,
+ variant = "swatch",
+ },
+ {
+ id = "sectionRow",
+ type = "sectionList",
+ height = 120,
+ sections = function()
+ return {}
+ end,
+ },
+ },
+ },
+ },
},
},
})
- local page = root:GetSection("rows"):GetPage("main")
- local category = page._category
-
- local listInit = SB.List({
- category = category,
- height = 120,
- items = function()
- return {}
- end,
- variant = "swatch",
- })
- local sectionInit = SB.SectionList({
- category = category,
- height = 120,
- sections = function()
- return {}
- end,
- })
+ local page = SB:GetPage("rows", "main")
+ local initializers = page._category:GetLayout()._initializers
+ local listInit = initializers[1]
+ local sectionInit = initializers[2]
- assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, listInit._template)
- assert.are.equal(SB.EMBED_CANVAS_TEMPLATE, sectionInit._template)
+ assert.are.equal("SettingsListElementTemplate", listInit._template)
+ assert.are.equal("SettingsListElementTemplate", sectionInit._template)
assert.is_function(page.Refresh)
end)
end)
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index cb5c1a27..09934efd 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -33,9 +33,11 @@ describe("LibSettingsBuilder Core", function()
it("loads the split library through the shared ordered loader", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
assert.is_table(lsb)
- assert.is_function(lsb.PathAdapter)
- assert.is_function(lsb.CreateColorSwatch)
assert.is_table(lsb.LSBDeprecated)
+ assert.is_nil(lsb.BuilderMixin)
+ assert.is_nil(lsb.CanvasLayout)
+ assert.is_nil(lsb.CanvasLayoutDefaults)
+ assert.is_nil(lsb.CreateColorSwatch)
assert.is_nil(lsb._loadState.open)
end)
@@ -43,16 +45,49 @@ describe("LibSettingsBuilder Core", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local deprecated = lsb.LSBDeprecated
- assert.are.equal(lsb.CreateColorSwatch, deprecated.CreateColorSwatch)
- assert.are.equal(lsb.CreateHeaderTitle, deprecated.CreateHeaderTitle)
- assert.are.equal(lsb.CreateSubheaderTitle, deprecated.CreateSubheaderTitle)
+ assert.is_function(deprecated.CreateColorSwatch)
+ assert.is_function(deprecated.CreateHeaderTitle)
+ assert.is_function(deprecated.CreateSubheaderTitle)
assert.is_function(deprecated.CreateCanvasLayout)
assert.is_function(deprecated.SetCanvasLayoutDefaults)
assert.is_function(deprecated.ConfigureCanvasLayout)
- assert.are.equal(lsb.CanvasLayout, deprecated.CanvasLayout)
+ assert.is_table(deprecated.CanvasLayout)
end)
- it("PathAdapter resolves nested values and defaults", function()
+ it("exposes only the planned public runtime surface on builder instances", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local sb = lsb.New({
+ name = "Phase 2",
+ onChanged = function() end,
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { type = "info", name = "Version", value = "1.0" },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ assert.is_function(sb.GetSection)
+ assert.is_function(sb.GetRootPage)
+ assert.is_function(sb.GetPage)
+ assert.is_function(sb.HasCategory)
+ assert.is_nil(sb.GetRoot)
+ assert.is_nil(sb.Register)
+ assert.is_nil(sb.EmbedCanvas)
+ assert.is_nil(sb.Checkbox)
+ assert.is_nil(sb.List)
+ assert.is_nil(sb.Control)
+ end)
+
+ it("store/defaults bindings resolve nested values and defaults", function()
local profile = {
root = {
enabled = true,
@@ -64,20 +99,107 @@ describe("LibSettingsBuilder Core", function()
},
}
local lsb = LibStub("LibSettingsBuilder-1.0")
- local adapter = lsb.PathAdapter({
- getStore = function()
+ local sb = lsb.New({
+ name = "Store Binding",
+ store = function()
return profile
end,
- getDefaults = function()
+ defaults = function()
return defaults
end,
+ onChanged = function() end,
})
- local binding = adapter:resolve("root.enabled")
+ local binding = sb._adapter:resolve("root.enabled")
assert.are.equal(true, binding.get())
assert.are.equal(false, binding.default)
binding.set(false)
assert.are.equal(false, profile.root.enabled)
end)
+
+ it("registers canonical raw row tables without public helper constructors", function()
+ local profile = {
+ general = {
+ enabled = true,
+ threshold = 5,
+ },
+ }
+ local defaults = {
+ general = {
+ enabled = false,
+ threshold = 0,
+ },
+ }
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local sb = lsb.New({
+ name = "Phase 2",
+ store = function()
+ return profile
+ end,
+ defaults = function()
+ return defaults
+ end,
+ onChanged = function() end,
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { id = "enabled", type = "checkbox", path = "general.enabled", name = "Enable" },
+ {
+ id = "threshold",
+ type = "slider",
+ path = "general.threshold",
+ name = "Threshold",
+ min = 0,
+ max = 10,
+ step = 1,
+ formatValue = function(value)
+ return tostring(value)
+ end,
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ assert.has_no.errors(function()
+ local page = sb:GetPage("general", "main")
+ assert.is_table(page)
+ assert.are.equal("Phase 2.General", page:GetId())
+ end)
+ end)
+
+ it("fails early when a raw path-bound row is registered without a path adapter", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local ok, err = pcall(function()
+ lsb.New({
+ name = "Phase 2 Invalid",
+ onChanged = function() end,
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { type = "checkbox", path = "general.enabled", name = "Enable" },
+ },
+ },
+ },
+ },
+ },
+ })
+ end)
+
+ assert.is_false(ok)
+ assert.is_truthy(tostring(err):find("requires store/defaults on the builder", 1, true))
+ end)
end)
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 4e608343..c09a191b 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -12,11 +12,19 @@ local internal = lib._internal
local copyMixin = internal.copyMixin
local installPageLifecycleHooks = internal.installPageLifecycleHooks
local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
-local BuilderMixin = lib.BuilderMixin
+local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
-local SectionMethods = {}
-local PageMethods = {}
+local PUBLIC_BUILDER_METHODS = {
+ GetSection = true,
+ GetRootPage = true,
+ GetPage = true,
+ HasCategory = true,
+}
+
+internal.publicBuilderMethods = PUBLIC_BUILDER_METHODS
+
+local PublicPageMethods = {}
local DISPATCH = {
checkbox = "Checkbox",
@@ -29,15 +37,15 @@ local DISPATCH = {
local COMPOSITE_ROW_DISPATCH = {
border = function(builder, path, spec)
- local result = builder:BorderGroup(path, spec)
+ local result = BuilderMixin.BorderGroup(builder, path, spec)
return result.enabledInit, result.enabledSetting
end,
fontOverride = function(builder, path, spec)
- local result = builder:FontOverrideGroup(path, spec)
+ local result = BuilderMixin.FontOverrideGroup(builder, path, spec)
return result.enabledInit, result.enabledSetting
end,
heightOverride = function(builder, path, spec)
- return builder:HeightOverrideSlider(path, spec)
+ return BuilderMixin.HeightOverrideSlider(builder, path, spec)
end,
}
@@ -50,41 +58,42 @@ local PROXY_ROW_TYPES = {
custom = true,
}
-local proxyMethods = {}
-local proxyMT = {
- __index = function(self, key)
- local method = proxyMethods[key]
- if method then
- return method
- end
-
- local target = rawget(self, "_lsbTarget")
- if not target then
- return nil
- end
-
- local value = target[key]
- if type(value) == "function" then
- return function(_, ...)
- return value(target, ...)
- end
- end
-
- return value
- end,
+local COMPOSITE_ROW_TYPES = {
+ border = true,
+ checkboxList = true,
+ colorList = true,
+ fontOverride = true,
+ heightOverride = true,
}
-function proxyMethods:_lsbBind(target)
- self._lsbTarget = target
- return target
-end
+local VALID_ROW_TYPES = {
+ border = true,
+ button = true,
+ canvas = true,
+ checkbox = true,
+ checkboxList = true,
+ color = true,
+ colorList = true,
+ custom = true,
+ dropdown = true,
+ fontOverride = true,
+ header = true,
+ heightOverride = true,
+ info = true,
+ input = true,
+ list = true,
+ pageActions = true,
+ sectionList = true,
+ slider = true,
+ subheader = true,
+}
function BuilderMixin:SetCanvasLayoutDefaults(overrides)
if not overrides then
- return lib.CanvasLayoutDefaults
+ return internal.CanvasLayoutDefaults
end
- return copyMixin(lib.CanvasLayoutDefaults, overrides)
+ return copyMixin(internal.CanvasLayoutDefaults, overrides)
end
function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
@@ -93,7 +102,7 @@ function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
return getCanvasLayoutMetrics(layout)
end
- layout._metrics = copyMixin(copyMixin({}, lib.CanvasLayoutDefaults), overrides)
+ layout._metrics = copyMixin(copyMixin({}, internal.CanvasLayoutDefaults), overrides)
return layout._metrics
end
@@ -108,7 +117,7 @@ end
function BuilderMixin:Control(spec)
local methodName = DISPATCH[spec.type]
assert(methodName, "Control: unknown type '" .. tostring(spec.type) .. "'")
- return self[methodName](self, spec)
+ return BuilderMixin[methodName](self, spec)
end
local function refreshCategory(builder, category)
@@ -145,90 +154,159 @@ local function refreshCategory(builder, category)
end
end
-local function shouldProcessRow(row)
- local condition = row and row.condition
- return condition == nil
- or (type(condition) == "function" and condition())
- or (type(condition) ~= "function" and condition)
-end
-
local function resolvePagePath(pagePath, rowPath)
- if not rowPath or rowPath:find("%.") or pagePath == "" then
- return rowPath or pagePath
+ if rowPath == nil or rowPath == "" or rowPath:find("%.") or pagePath == "" then
+ return (rowPath ~= nil and rowPath ~= "") and rowPath or pagePath
end
return pagePath .. "." .. rowPath
end
-local function copyDeclarativeRowSpec(row)
+local function assertBooleanOrCallback(sourceName, fieldName, value)
+ local valueType = type(value)
+ assert(
+ value == nil or valueType == "boolean" or valueType == "function",
+ sourceName .. ": " .. fieldName .. " must be a boolean or function"
+ )
+end
+
+local function getRowLabel(row)
+ return tostring(row.id or row.key or row.path or row.name or row.type)
+end
+
+local function normalizeDeclarativeRowSpec(sourceName, row)
+ assert(type(row) == "table", sourceName .. ": each row must be a table")
+
local spec = copyMixin({}, row)
- spec.id = nil
- spec.condition = nil
- if spec.desc and not spec.tooltip then
- spec.tooltip = spec.desc
+ assert(spec.desc == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses deprecated field 'desc'; use 'tooltip'")
+ assert(spec.condition == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'condition'")
+ assert(spec.parent == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'parent'")
+ assert(spec.parentCheck == nil, sourceName .. ": row '" .. getRowLabel(spec) .. "' uses removed field 'parentCheck'")
+
+ local rowType = spec.type
+ assert(type(rowType) == "string" and VALID_ROW_TYPES[rowType], sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
+
+ if rowType == "button" and spec.buttonText == nil and spec.value ~= nil then
+ spec.buttonText = spec.value
end
- spec.desc = nil
- return spec
-end
+ spec.value = rowType == "button" and nil or spec.value
-local function resolveDeclarativeParent(sourceName, created, rowID, spec)
- if type(spec.parent) ~= "string" then
- return
+ if rowType == "dropdown" and spec.scrollHeight == nil and spec.maxScrollDisplayHeight ~= nil then
+ spec.scrollHeight = spec.maxScrollDisplayHeight
end
+ spec.maxScrollDisplayHeight = nil
- local ref = created[spec.parent]
- assert(
- ref,
- sourceName .. ": parent '" .. spec.parent .. "' not found for row '" .. tostring(rowID or spec.name or spec.type) .. "'"
- )
+ if rowType == "info" and spec.values ~= nil then
+ assert(spec.value == nil, sourceName .. ": info row '" .. getRowLabel(spec) .. "' cannot define both value and values")
+ assert(type(spec.values) == "table", sourceName .. ": info row '" .. getRowLabel(spec) .. "' values must be a table")
+ spec.value = table.concat(spec.values, "\n")
+ spec.multiline = true
+ spec.values = nil
+ end
- spec.parent = ref.initializer
- local parentCheck = spec.parentCheck
- if parentCheck == "checked" or parentCheck == "notChecked" then
- local setting = ref.setting
- assert(setting, sourceName .. ": parentCheck='" .. parentCheck .. "' requires a parent setting")
- spec.parentCheck = parentCheck == "checked" and function()
- return setting:GetValue()
- end or function()
- return not setting:GetValue()
- end
+ if rowType == "input" and spec.debounce == nil and spec.debounceMilliseconds ~= nil then
+ spec.debounce = spec.debounceMilliseconds / 1000
end
-end
+ spec.debounceMilliseconds = nil
+
+ if rowType == "slider" and spec.formatter == nil and spec.formatValue ~= nil then
+ spec.formatter = spec.formatValue
+ end
+ spec.formatValue = nil
+
+ spec.id = row.id
-local function createProxy(kind)
- return setmetatable({ _lsbProxyKind = kind }, proxyMT)
+ return spec
end
-local function bindProxy(proxy, target)
- if proxy then
- proxy:_lsbBind(target)
+local function validateDeclarativeRow(sourceName, builder, row)
+ local rowType = row.type
+ local rowLabel = getRowLabel(row)
+ local hasHandler = row.get ~= nil or row.set ~= nil
+
+ assertBooleanOrCallback(sourceName, "disabled", row.disabled)
+ assertBooleanOrCallback(sourceName, "hidden", row.hidden)
+
+ if PROXY_ROW_TYPES[rowType] then
+ if hasHandler then
+ assert(row.get, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires get")
+ assert(row.set, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires set")
+ assert(row.key or row.id, sourceName .. ": handler-mode row '" .. rowLabel .. "' requires key or id")
+ else
+ assert(row.path ~= nil, sourceName .. ": path-bound row '" .. rowLabel .. "' requires path")
+ assert(builder._adapter, sourceName .. ": path-bound row '" .. rowLabel .. "' requires store/defaults on the builder")
+ end
+ end
+
+ if rowType == "button" then
+ assert(type(row.onClick) == "function", sourceName .. ": button row '" .. rowLabel .. "' requires onClick")
+ elseif rowType == "canvas" then
+ assert(row.canvas, sourceName .. ": canvas row '" .. rowLabel .. "' requires canvas")
+ elseif rowType == "custom" then
+ assert(row.template, sourceName .. ": custom row '" .. rowLabel .. "' requires template")
+ elseif rowType == "dropdown" then
+ assert(row.values ~= nil, sourceName .. ": dropdown row '" .. rowLabel .. "' requires values")
+ elseif rowType == "list" then
+ assert(type(row.items) == "function", sourceName .. ": list row '" .. rowLabel .. "' requires items")
+ assert(row.height, sourceName .. ": list row '" .. rowLabel .. "' requires height")
+ elseif rowType == "pageActions" then
+ assert(type(row.actions) == "table", sourceName .. ": pageActions row '" .. rowLabel .. "' requires actions")
+ elseif rowType == "sectionList" then
+ assert(type(row.sections) == "function", sourceName .. ": sectionList row '" .. rowLabel .. "' requires sections")
+ assert(row.height, sourceName .. ": sectionList row '" .. rowLabel .. "' requires height")
+ elseif rowType == "slider" then
+ assert(row.min ~= nil, sourceName .. ": slider row '" .. rowLabel .. "' requires min")
+ assert(row.max ~= nil, sourceName .. ": slider row '" .. rowLabel .. "' requires max")
+ elseif COMPOSITE_ROW_TYPES[rowType] then
+ assert(row.path ~= nil, sourceName .. ": composite row '" .. rowLabel .. "' requires path")
+ if rowType == "checkboxList" or rowType == "colorList" then
+ assert(type(row.defs) == "table", sourceName .. ": composite row '" .. rowLabel .. "' requires defs")
+ end
+ elseif rowType == "info" then
+ assert(
+ row.value ~= nil or row.values ~= nil or row.name ~= nil,
+ sourceName .. ": info row '" .. rowLabel .. "' requires value, values, or name"
+ )
+ elseif rowType == "header" or rowType == "subheader" then
+ assert(row.name ~= nil, sourceName .. ": " .. rowType .. " row '" .. rowLabel .. "' requires name")
end
- return target
end
-local function unwrapProxy(value, kind, sourceName)
- if type(value) ~= "table" or value._lsbProxyKind ~= kind then
- return value
+local function validateDeclarativeRows(sourceName, builder, rows, seenRowIDs)
+ assert(type(rows) == "table", sourceName .. ": rows must be a table")
+
+ for _, row in ipairs(rows) do
+ local normalized = normalizeDeclarativeRowSpec(sourceName, row)
+ local rowID = normalized.id
+ if rowID ~= nil then
+ assert(not seenRowIDs[rowID], sourceName .. ": duplicate row id '" .. tostring(rowID) .. "'")
+ seenRowIDs[rowID] = true
+ end
+ validateDeclarativeRow(sourceName, builder, normalized)
end
+end
- local target = rawget(value, "_lsbTarget")
- assert(target, sourceName .. ": dependent control was not materialized yet")
- return target
+local function validatePageDefinition(sourceName, pageDef)
+ assert(type(pageDef) == "table", sourceName .. ": page definition must be a table")
+ assert(pageDef.key, sourceName .. ": page definition requires key")
+ assert(type(pageDef.rows) == "table", sourceName .. ": page definition requires rows")
end
local function callBuilder(builder, methodName, ...)
- return builder[methodName](builder, ...)
+ local method = BuilderMixin[methodName]
+ assert(type(method) == "function", "callBuilder: unknown builder method '" .. tostring(methodName) .. "'")
+ return method(builder, ...)
end
local function registerLabeledList(page, spec, methodName)
local builder = page._builder
if spec.label then
- local labelInit = builder:Subheader({
+ local labelInit = callBuilder(builder, "Subheader", {
name = spec.label,
disabled = spec.disabled,
hidden = spec.hidden,
category = page._category,
})
- spec.parent = spec.parent or labelInit
+ spec._parentInitializer = spec._parentInitializer or labelInit
end
local results = callBuilder(
@@ -242,11 +320,10 @@ local function registerLabeledList(page, spec, methodName)
end
local function registerDeclarativeRow(sourceName, page, row, created)
- local rowType = row.type
- assert(rowType, sourceName .. ": each row requires a type")
+ local spec = normalizeDeclarativeRowSpec(sourceName, row)
+ local rowType = spec.type
local builder = page._builder
- local spec = copyDeclarativeRowSpec(row)
if page.disabled and spec.disabled == nil then
spec.disabled = page.disabled
end
@@ -257,37 +334,29 @@ local function registerDeclarativeRow(sourceName, page, row, created)
spec.category = page._category
end
- resolveDeclarativeParent(sourceName, created, row.id, spec)
spec._page = page
- if spec.onClick then
- local original = spec.onClick
- spec.onClick = function(...)
- return original(page, ...)
- end
- end
-
local initializer, setting
if rowType == "button" then
- initializer = builder:Button(spec)
+ initializer = callBuilder(builder, "Button", spec)
elseif rowType == "canvas" then
- initializer = builder:EmbedCanvas(spec.canvas, spec.height, spec)
+ initializer = callBuilder(builder, "EmbedCanvas", spec.canvas, spec.height, spec)
elseif rowType == "checkboxList" then
initializer, setting = registerLabeledList(page, spec, "CheckboxList")
elseif rowType == "colorList" then
initializer, setting = registerLabeledList(page, spec, "ColorPickerList")
elseif rowType == "header" then
- initializer = builder:Header(spec)
+ initializer = callBuilder(builder, "Header", spec)
elseif rowType == "info" then
- initializer = builder:InfoRow(spec)
+ initializer = callBuilder(builder, "InfoRow", spec)
elseif rowType == "list" then
- initializer = builder:List(spec)
+ initializer = callBuilder(builder, "List", spec)
elseif rowType == "pageActions" then
- initializer = builder:PageActions(spec)
+ initializer = callBuilder(builder, "PageActions", spec)
elseif rowType == "sectionList" then
- initializer = builder:SectionList(spec)
+ initializer = callBuilder(builder, "SectionList", spec)
elseif rowType == "subheader" then
- initializer = builder:Subheader(spec)
+ initializer = callBuilder(builder, "Subheader", spec)
elseif COMPOSITE_ROW_DISPATCH[rowType] then
initializer, setting = COMPOSITE_ROW_DISPATCH[rowType](
builder,
@@ -304,7 +373,7 @@ local function registerDeclarativeRow(sourceName, page, row, created)
error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id")
end
spec.type = rowType
- initializer, setting = builder:Control(spec)
+ initializer, setting = callBuilder(builder, "Control", spec)
else
error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
end
@@ -354,136 +423,11 @@ local function bindPageLifecycle(page)
end
end
-local function prepareSpec(page, sourceName, spec)
- local prepared = copyMixin({}, spec)
- prepared._page = page
- if prepared.category == nil then
- prepared.category = page._category
- end
- if prepared.parent then
- prepared.parent = unwrapProxy(prepared.parent, "initializer", sourceName)
- end
- if prepared.onClick then
- local original = prepared.onClick
- prepared.onClick = function(...)
- return original(page, ...)
- end
- end
- return prepared
-end
-
-local function prepareControlSpec(page, sourceName, spec)
- local prepared = prepareSpec(page, sourceName, spec)
- if not prepared.get and prepared.path then
- prepared.path = resolvePagePath(page.path or "", prepared.path)
- end
- return prepared
-end
-
local function queuePageOperation(page, sourceName, fn)
assertPageMutable(page, sourceName)
page._operations[#page._operations + 1] = fn
end
-local function queueSpecPair(page, sourceName, methodName, spec)
- local initializerProxy = createProxy("initializer")
- local settingProxy = createProxy("setting")
- local snapshot = copyMixin({}, spec or {})
-
- queuePageOperation(page, sourceName, function()
- local initializer, setting = callBuilder(
- page._builder,
- methodName,
- prepareControlSpec(page, sourceName, snapshot)
- )
- bindProxy(initializerProxy, initializer)
- bindProxy(settingProxy, setting)
- end)
-
- return initializerProxy, settingProxy
-end
-
-local function queueSpecInit(page, sourceName, methodName, spec)
- local initializerProxy = createProxy("initializer")
- local snapshot = copyMixin({}, spec or {})
-
- queuePageOperation(page, sourceName, function()
- local initializer = callBuilder(page._builder, methodName, prepareSpec(page, sourceName, snapshot))
- bindProxy(initializerProxy, initializer)
- end)
-
- return initializerProxy
-end
-
-local function queueHeightOverride(page, sectionPath, spec)
- local initializerProxy = createProxy("initializer")
- local settingProxy = createProxy("setting")
- local snapshot = copyMixin({}, spec or {})
-
- queuePageOperation(page, "page:HeightOverrideSlider", function()
- local initializer, setting = callBuilder(
- page._builder,
- "HeightOverrideSlider",
- resolvePagePath(page.path or "", sectionPath),
- prepareSpec(page, "page:HeightOverrideSlider", snapshot)
- )
- bindProxy(initializerProxy, initializer)
- bindProxy(settingProxy, setting)
- end)
-
- return initializerProxy, settingProxy
-end
-
-local function queueCompositeGroup(page, sourceName, methodName, basePath, spec, fields)
- local result = {}
- for key, kind in pairs(fields) do
- result[key] = createProxy(kind)
- end
-
- local snapshot = copyMixin({}, spec or {})
- queuePageOperation(page, sourceName, function()
- local actual = callBuilder(
- page._builder,
- methodName,
- resolvePagePath(page.path or "", basePath),
- prepareSpec(page, sourceName, snapshot)
- )
- for key in pairs(fields) do
- bindProxy(result[key], actual[key])
- end
- end)
-
- return result
-end
-
-local function queueCompositeList(page, sourceName, methodName, basePath, defs, spec)
- local proxies = {}
- for i, def in ipairs(defs or {}) do
- proxies[i] = {
- key = def.key,
- initializer = createProxy("initializer"),
- setting = createProxy("setting"),
- }
- end
-
- local snapshot = copyMixin({}, spec or {})
- queuePageOperation(page, sourceName, function()
- local actual = callBuilder(
- page._builder,
- methodName,
- resolvePagePath(page.path or "", basePath),
- defs,
- prepareSpec(page, sourceName, snapshot)
- )
- for i, proxy in ipairs(proxies) do
- bindProxy(proxy.initializer, actual[i] and actual[i].initializer)
- bindProxy(proxy.setting, actual[i] and actual[i].setting)
- end
- end)
-
- return proxies
-end
-
local function materializePage(page, category)
assert(not page._registered, "materializePage: page is already registered")
page._category = category
@@ -494,138 +438,27 @@ local function materializePage(page, category)
operation(created)
end
- setmetatable(page, { __index = PageMethods })
+ setmetatable(page, { __index = PublicPageMethods })
page._registered = true
- if page._onRegistered then
- page._onRegistered(page)
- end
return page
end
local function appendDeclarativeRows(page, sourceName, rows)
+ validateDeclarativeRows(sourceName, page._builder, rows, page._rowIDs)
queuePageOperation(page, sourceName, function(created)
- for _, row in ipairs(rows or {}) do
- if shouldProcessRow(row) then
- registerDeclarativeRow(sourceName, page, row, created)
- end
+ for _, row in ipairs(rows) do
+ registerDeclarativeRow(sourceName, page, row, created)
end
end)
return page
end
-function PageMethods:RegisterRows(rows)
- return appendDeclarativeRows(self, "page:RegisterRows", rows)
-end
-
-function PageMethods:Checkbox(spec)
- return queueSpecPair(self, "page:Checkbox", "Checkbox", spec)
-end
-
-function PageMethods:Slider(spec)
- return queueSpecPair(self, "page:Slider", "Slider", spec)
-end
-
-function PageMethods:Dropdown(spec)
- return queueSpecPair(self, "page:Dropdown", "Dropdown", spec)
-end
-
-function PageMethods:Input(spec)
- return queueSpecPair(self, "page:Input", "Input", spec)
-end
-
-function PageMethods:Color(spec)
- return queueSpecPair(self, "page:Color", "Color", spec)
-end
-
-function PageMethods:Custom(spec)
- return queueSpecPair(self, "page:Custom", "Custom", spec)
-end
-
-function PageMethods:Button(spec)
- return queueSpecInit(self, "page:Button", "Button", spec)
-end
-
-function PageMethods:PageActions(spec)
- return queueSpecInit(self, "page:PageActions", "PageActions", spec)
-end
-
-function PageMethods:Header(spec)
- if type(spec) ~= "table" then
- spec = { name = spec }
- end
- return queueSpecInit(self, "page:Header", "Header", spec)
-end
-
-function PageMethods:Subheader(spec)
- return queueSpecInit(self, "page:Subheader", "Subheader", spec)
-end
-
-function PageMethods:InfoRow(spec)
- return queueSpecInit(self, "page:InfoRow", "InfoRow", spec)
-end
-
-function PageMethods:List(spec)
- return queueSpecInit(self, "page:List", "List", spec)
-end
-
-function PageMethods:SectionList(spec)
- return queueSpecInit(self, "page:SectionList", "SectionList", spec)
-end
-
-function PageMethods:EmbedCanvas(canvas, height, spec)
- local initializerProxy = createProxy("initializer")
- local snapshot = copyMixin({}, spec or {})
-
- queuePageOperation(self, "page:EmbedCanvas", function()
- local initializer = callBuilder(
- self._builder,
- "EmbedCanvas",
- canvas,
- height,
- prepareSpec(self, "page:EmbedCanvas", snapshot)
- )
- bindProxy(initializerProxy, initializer)
- end)
-
- return initializerProxy
-end
-
-function PageMethods:HeightOverrideSlider(sectionPath, spec)
- return queueHeightOverride(self, sectionPath, spec)
-end
-
-function PageMethods:FontOverrideGroup(sectionPath, spec)
- return queueCompositeGroup(self, "page:FontOverrideGroup", "FontOverrideGroup", sectionPath, spec, {
- enabledInit = "initializer",
- enabledSetting = "setting",
- fontInit = "initializer",
- sizeInit = "initializer",
- })
-end
-
-function PageMethods:BorderGroup(borderPath, spec)
- return queueCompositeGroup(self, "page:BorderGroup", "BorderGroup", borderPath, spec, {
- enabledInit = "initializer",
- enabledSetting = "setting",
- thicknessInit = "initializer",
- colorInit = "initializer",
- })
-end
-
-function PageMethods:ColorPickerList(basePath, defs, spec)
- return queueCompositeList(self, "page:ColorPickerList", "ColorPickerList", basePath, defs, spec)
-end
-
-function PageMethods:CheckboxList(basePath, defs, spec)
- return queueCompositeList(self, "page:CheckboxList", "CheckboxList", basePath, defs, spec)
-end
-
-function PageMethods:GetID()
- assert(self._registered and self._category, "page:GetID: page is not registered")
+function PublicPageMethods:GetId()
+ assert(self._registered and self._category, "page:GetId: page is not registered")
return self._category:GetID()
end
-function PageMethods:Refresh()
+function PublicPageMethods:Refresh()
assert(self._registered and self._category, "page:Refresh: page is not registered")
refreshCategory(self._builder, self._category)
end
@@ -643,8 +476,8 @@ local function createPage(owner, key, rows, opts)
_name = opts.name,
_onShow = opts.onShow,
_onHide = opts.onHide,
- _onRegistered = opts.onRegistered,
_operations = {},
+ _rowIDs = {},
_registered = false,
disabled = opts.disabled,
hidden = opts.hidden,
@@ -652,7 +485,7 @@ local function createPage(owner, key, rows, opts)
name = opts.name,
order = opts.order,
path = opts.path ~= nil and opts.path or ownerPath,
- }, { __index = PageMethods })
+ }, { __index = PublicPageMethods })
if rows then
appendDeclarativeRows(page, "CreatePage", rows)
@@ -661,10 +494,6 @@ local function createPage(owner, key, rows, opts)
return page
end
-function SectionMethods:GetPage(key)
- return self._pages[key]
-end
-
local function createSectionPage(section, key, rows, opts)
assert(not section._registered, "createSectionPage: section is already registered")
assert(key, "createSectionPage: key is required")
@@ -695,7 +524,7 @@ local function registerSection(section)
assert(#section._pageList > 0, "registerSection: section must contain at least one page")
local builder = section._builder
- local nested = section.display == "nested" or #section._pageList > 1
+ local nested = #section._pageList > 1
local orderedPages = {}
for i = 1, #section._pageList do
orderedPages[i] = section._pageList[i]
@@ -726,10 +555,7 @@ local function createSection(root, key, name, opts)
opts = opts or {}
root._nextSectionSequence = root._nextSectionSequence + 1
- local display = opts.display or "auto"
- assert(display == "auto" or display == "nested", "createSection: display must be 'auto' or 'nested'")
-
- local section = setmetatable({
+ local section = {
_builder = root,
_root = root,
_pages = {},
@@ -737,12 +563,11 @@ local function createSection(root, key, name, opts)
_nextPageSequence = 0,
_registered = false,
_sequence = root._nextSectionSequence,
- display = display,
key = key,
name = name,
order = opts.order,
path = opts.path ~= nil and opts.path or key,
- }, { __index = SectionMethods })
+ }
root._sections[key] = section
root._sectionList[#root._sectionList + 1] = section
@@ -765,13 +590,17 @@ function BuilderMixin:GetSection(key)
return self._sections[key]
end
-function BuilderMixin:GetPage(key)
- return self._pages[key]
+function BuilderMixin:GetRootPage()
+ return self._registeredRootPage
end
-function BuilderMixin:GetRoot(name)
- self:_initializeRoot(name)
- return self
+function BuilderMixin:GetPage(sectionKey, pageKey)
+ if pageKey == nil then
+ return nil
+ end
+
+ local section = self._sections[sectionKey]
+ return section and section._pages[pageKey] or nil
end
function BuilderMixin:HasCategory(category)
@@ -779,15 +608,13 @@ function BuilderMixin:HasCategory(category)
end
local function registerPageDefinition(owner, pageDef, defaultName)
- assert(type(pageDef) == "table", "registerPageDefinition: page definition must be a table")
- assert(pageDef.key, "registerPageDefinition: page definition requires key")
+ validatePageDefinition("registerPageDefinition", pageDef)
local creator = owner._root and createSectionPage or createRootPage
return creator(owner, pageDef.key, pageDef.rows, {
name = pageDef.name or defaultName,
onShow = pageDef.onShow,
onHide = pageDef.onHide,
- onRegistered = pageDef.onRegistered,
disabled = pageDef.disabled,
hidden = pageDef.hidden,
order = pageDef.order,
@@ -795,9 +622,10 @@ local function registerPageDefinition(owner, pageDef, defaultName)
})
end
-function BuilderMixin:Register(spec)
+function BuilderMixin:_registerTree(spec)
assertRootConfigured(self, "Register")
assert(type(spec) == "table", "Register: spec must be a table")
+ assert(spec.page or spec.sections, "Register: spec requires page or sections")
if spec.page then
registerRootPage(self, registerPageDefinition(self, spec.page, self.name))
@@ -809,26 +637,13 @@ function BuilderMixin:Register(spec)
assert(sectionDef.name, "Register: each section requires a name")
local section = createSection(self, sectionDef.key, sectionDef.name, {
- display = sectionDef.display,
order = sectionDef.order,
path = sectionDef.path,
})
- if sectionDef.pages then
- assert(sectionDef.rows == nil, "Register: a section cannot define both rows and pages")
- for _, pageDef in ipairs(sectionDef.pages) do
- registerPageDefinition(section, pageDef, sectionDef.name)
- end
- else
- createSectionPage(section, sectionDef.pageKey or "main", sectionDef.rows, {
- name = sectionDef.pageName or (sectionDef.display == "nested" and sectionDef.name or nil),
- onShow = sectionDef.onShow,
- onHide = sectionDef.onHide,
- onRegistered = sectionDef.onRegistered,
- disabled = sectionDef.disabled,
- hidden = sectionDef.hidden,
- order = sectionDef.pageOrder,
- })
+ assert(type(sectionDef.pages) == "table", "Register: each section requires a pages array")
+ for _, pageDef in ipairs(sectionDef.pages) do
+ registerPageDefinition(section, pageDef, sectionDef.name)
end
registerSection(section)
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index 5ccf5567..cdc8fa24 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -10,7 +10,7 @@
## v2 Freeze
-Phase 1 freezes the intended v2 public surface, but does not yet remove the compatibility APIs that the current addon still uses.
+Phases 1 and 2 freeze the intended v2 public surface and make raw declarative rows the canonical registration schema.
Target v2 surface:
@@ -21,9 +21,10 @@ Target v2 surface:
- `lsb:HasCategory(category)`
- `page:GetId()`
- `page:Refresh()`
+- raw row tables at registration boundaries
- deprecated compatibility namespace: `LSBDeprecated`
-Current compatibility APIs documented below remain live until the later migration phases replace them.
+Builder-level row helper constructors are no longer part of the public `lsb` instance surface. Use raw row tables through `LSB.New({ ... })`.
### `LSBDeprecated`
@@ -41,65 +42,30 @@ Currently exposed there:
## Factory
-### `LSB:New(config)`
+### `LSB.New(config)`
Required fields:
-- `varPrefix`
-- `onChanged(spec, value)`
+- `name`
+- `onChanged(ctx, value)`
Optional fields:
-- `pathAdapter`
-- `compositeDefaults`
-
-Returns a builder instance referred to as `SB` in the examples below.
-
-## Path adapters
-
-### `LSB.PathAdapter(config)`
-
-Required:
-
-- `getStore()`
-- `getDefaults()`
-
-Optional:
+- `store`
+- `defaults`
+- `page`
+- `sections`
-- `getNestedValue(tbl, path)`
-- `setNestedValue(tbl, path, value)`
-
-Methods:
-
-- `adapter:resolve(path)` → `{ get, set, default }`
-- `adapter:read(path)` → current value
-
-The built-in path helpers support numeric segments like `colors.0`.
+Returns an `lsb` runtime instance bound to one category tree.
## Registration tree
-### `SB.GetRoot(name)`
-
-Returns the singleton root handle, creating and registering the addon root category on the first call.
-
-Required on first call:
-
-- `name`
-
-Notes:
-
-- later calls return the same root handle,
-- passing a different name after creation raises an error,
-- new consumer code should call this once and reuse the returned handle.
-
-### `root:Register(spec)`
-
-Registers a declarative tree rooted at the singleton root handle.
+`LSB.New(config)` accepts and registers the full declarative tree.
Supported fields:
-- `spec.page` — optional root-owned landing page definition
-- `spec.sections` — optional array of section definitions
+- `config.page` — optional root-owned landing page definition
+- `config.sections` — optional array of section definitions
Root page definition fields:
@@ -108,7 +74,6 @@ Root page definition fields:
- `name` (optional; defaults to the root name)
- `onShow`
- `onHide`
-- `onRegistered(page)`
- `order`
Section definition fields:
@@ -116,11 +81,8 @@ Section definition fields:
- `key`
- `name`
- `path` (defaults to `key`)
-- `display = "auto"` or `"nested"`
- `order`
-- either `rows` for the single-page shorthand, or `pages` for multi-page sections
-- `pageKey`, `pageName`, `pageOrder` for the single-page shorthand
-- `onShow`, `onHide`, `onRegistered(page)`, `disabled`, `hidden` for the single-page shorthand page
+- `pages`
Page definition fields inside `pages`:
@@ -130,7 +92,6 @@ Page definition fields inside `pages`:
- `rows`
- `onShow`
- `onHide`
-- `onRegistered(page)`
- `disabled`
- `hidden`
- `order`
@@ -138,18 +99,17 @@ Page definition fields inside `pages`:
Notes:
- single-page sections flatten to a single leaf by default,
-- multi-page sections create a visible section node automatically,
-- `onRegistered(page)` is the intended hook for storing a registered page handle when you need later `page:Refresh()` calls.
+- multi-page sections create a visible section node automatically.
Declarative root registration is the only supported page-construction API.
### Lookup and page operations
-- `root:GetSection(key)`
-- `root:GetPage(key)`
-- `root:HasCategory(category)`
-- `section:GetPage(key)`
-- `page:GetID()`
+- `lsb:GetSection(key)`
+- `lsb:GetRootPage()`
+- `lsb:GetPage(sectionKey, pageKey)`
+- `lsb:HasCategory(category)`
+- `page:GetId()`
- `page:Refresh()`
## Controls
@@ -166,18 +126,16 @@ Common spec fields:
- `default`
- `disabled`
- `hidden`
-- `parent`
-- `parentCheck`
- `getTransform`
- `setTransform`
- `onSet`
- `layout`
-### `SB.Checkbox(spec)`
+### `checkbox` row
Creates a boolean checkbox.
-### `SB.Slider(spec)`
+### `slider` row
Additional fields:
@@ -201,7 +159,7 @@ Dropdown values are emitted in deterministic order to keep menus stable between
Reads and writes `{ r, g, b, a }` tables through a hex proxy value.
-### `SB.Input(spec)`
+### `input` row
Creates a text input row using the standard settings-row layout.
@@ -212,19 +170,15 @@ Additional fields:
- `width`
- `debounce`
- `resolveText(value, setting, frame)`
-- `watch`
-- `watchVariables`
- `onTextChanged(text, setting, frame)`
Notes:
- the edit box writes through the same proxy-setting pipeline as the other built-in controls,
- `resolveText` enables an optional preview line below the edit box,
-- `debounce` delays preview recomputation through `C_Timer.NewTimer`,
-- `watch` accepts sibling spec identifiers and resolves them to this builder's proxy-setting variables,
-- `watchVariables` accepts already-resolved proxy-setting variable names.
+- `debounce` delays preview recomputation through `C_Timer.NewTimer`.
-### `SB.Custom(spec)`
+### `custom` row
Additional fields:
@@ -276,14 +230,14 @@ Supported list variants:
- `SB.FontOverrideGroup(sectionPath[, spec])`
- `SB.BorderGroup(borderPath[, spec])`
- `SB.ColorPickerList(basePath, defs[, spec])`
-- `SB.CheckboxList(basePath, defs[, spec])`
+- `checkboxList`
## Utility helpers
- `SB.Header(text[, category])`
- `SB.Subheader(spec)`
- `SB.InfoRow(spec)`
-- `SB.EmbedCanvas(canvas, height[, spec])`
+- `canvas`
- `SB.Button(spec)`
- `SB.PageActions(spec)`
@@ -317,7 +271,7 @@ Supported composite types:
- `colorList`
- `checkboxList`
-Declarative pages are normally supplied through `root:Register({ page = ..., sections = { ... } })`, either as a root page definition or through section `rows` / `pages` definitions.
+Declarative pages are normally supplied through `LSB.New({ page = ..., sections = { ... } })`, either as a root page definition or through section `rows` / `pages` definitions.
## Implementation model
@@ -328,9 +282,9 @@ The library has three main families of row builders:
- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `checkboxList`).
`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template.
-`canvas` rows stay on the current lifecycle path. Keep using `SB.EmbedCanvas(...)` for bespoke frames when a built-in row is not enough.
+`canvas` rows stay on the current lifecycle path. Keep using `type = "canvas"` rows for bespoke frames when a built-in row is not enough.
-#### `SB.SetCanvasLayoutDefaults(overrides)`
+#### `LSBDeprecated.SetCanvasLayoutDefaults(overrides)`
Merges overrides into the shared defaults table.
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index e00c7f53..11ba9c63 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -63,17 +63,17 @@ Most library features are available with no extra XML:
`input` is a built-in row type implemented entirely in Lua on top of `SettingsListElementTemplate` plus a runtime-created `InputBoxTemplate` edit box.
-Only `SB.Custom(...)` requires you to supply your own template. In that case:
+Only `type = "custom"` rows require you to supply your own template. In that case:
1. define the template in XML,
-2. load that XML from your TOC before calling `root:Register({ ... })`, and
+2. load that XML from your TOC before calling `LSB.New({ ... })`, and
3. pass the template name through `spec.template`.
## Canvas layout compatibility
Canvas layout spacing defaults are still available for older `CreateCanvasLayout(...)` pages. New `canvas` rows stay on the current lifecycle path, so canvas content continues to reuse the existing frame handling without special-case rewrites.
-- per-library via `SB.SetCanvasLayoutDefaults(overrides)`
-- per-layout via `SB.ConfigureCanvasLayout(layout, overrides)`
+- per-library via `LSBDeprecated.SetCanvasLayoutDefaults(overrides)`
+- per-layout via `LSBDeprecated.ConfigureCanvasLayout(layout, overrides)`
See [API Reference](API_REFERENCE.md) for examples.
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index 318e9882..ec292217 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -24,24 +24,18 @@
| AceConfig stack | LibSettingsBuilder |
|---|---|
| `RegisterOptionsTable` | export declarative root/page/section specs |
-| `AddToBlizOptions` | `SB.GetRoot("My Addon"):Register({ page = ..., sections = { ... } })` |
+| `AddToBlizOptions` | `LSB.New({ name = "My Addon", page = ..., sections = ... })` |
| one `get`/`set` per field | one `path` per field in path mode |
-| `type = "input"` | `type = "input"` or `SB.Input(...)` |
+| `type = "input"` | `type = "input"` |
| custom refresh dance | reactive modifiers re-evaluate automatically |
## Path mode replaces repeated getters and setters
```lua
-local SB = LSB:New({
- pathAdapter = LSB.PathAdapter({
- getStore = function()
- return db.profile
- end,
- getDefaults = function()
- return db.defaults.profile
- end,
- }),
- varPrefix = "MYADDON",
+local lsb = LSB.New({
+ name = "My Addon",
+ store = db.profile,
+ defaults = db.defaults.profile,
onChanged = function()
MyAddon:Refresh()
end,
@@ -81,9 +75,9 @@ Declarative pages use canonical row types only:
- specialized row templates,
- genuinely bespoke embedded frames.
-If you only need text or numeric entry, use the built-in `input` type first. Reach for `SB.Custom(...)` only when you need a genuinely different widget.
+If you only need text or numeric entry, use the built-in `input` type first. Reach for `type = "custom"` only when you need a genuinely different widget.
-If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "list"` or `type = "sectionList"` before reaching for `SB.Custom(...)` or `SB.EmbedCanvas(...)`.
+If you need an ordered list, grouped editor, or add/remove workflow, prefer `type = "list"` or `type = "sectionList"` before reaching for `type = "custom"` or `type = "canvas"`.
## Migrating AceConfig input fields
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index e892af6c..3f074f5f 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -15,60 +15,54 @@
- Use `input` rows when you need text or numeric entry without building a custom template.
- Use `list` or `sectionList` rows when you need ordered lists, grouped editors, or add/remove workflows without dropping into a bespoke frame API.
-## Declarative root setup
+## Declarative setup
```lua
local LSB = LibStub("LibSettingsBuilder-1.0")
-local SB = LSB:New({
- pathAdapter = LSB.PathAdapter({
- getStore = function()
- return MyAddonDB.profile
- end,
- getDefaults = function()
- return MyAddonDefaults.profile
- end,
- }),
- varPrefix = "MYADDON",
- onChanged = function()
+local lsb = LSB.New({
+ name = "My Addon",
+ store = MyAddonDB.profile,
+ defaults = MyAddonDefaults.profile,
+ onChanged = function(ctx)
MyAddon:Refresh()
end,
-})
-
-local root = SB.GetRoot("My Addon")
-
-root:Register({
sections = {
{
key = "general",
name = "General",
path = "general",
- rows = {
+ pages = {
{
- type = "checkbox",
- path = "enabled",
- name = "Enable",
- desc = "Enable or disable the addon.",
- },
- {
- type = "slider",
- path = "opacity",
- name = "Opacity",
- min = 0,
- max = 100,
- step = 1,
- },
- {
- type = "input",
- path = "spellIdText",
- name = "Spell ID",
- numeric = true,
- maxLetters = 10,
- debounce = 1,
- resolveText = function(value)
- local id = tonumber(value)
- return id and C_Spell.GetSpellName(id) or nil
- end,
+ key = "main",
+ rows = {
+ {
+ type = "checkbox",
+ path = "enabled",
+ name = "Enable",
+ tooltip = "Enable or disable the addon.",
+ },
+ {
+ type = "slider",
+ path = "opacity",
+ name = "Opacity",
+ min = 0,
+ max = 100,
+ step = 1,
+ },
+ {
+ type = "input",
+ path = "spellIdText",
+ name = "Spell ID",
+ numeric = true,
+ maxLetters = 10,
+ debounce = 1,
+ resolveText = function(value)
+ local id = tonumber(value)
+ return id and C_Spell.GetSpellName(id) or nil
+ end,
+ },
+ },
},
},
},
@@ -81,42 +75,44 @@ Declarative pages can mix persisted controls and layout-only rows freely, so it
## Handler mode
```lua
-local SB = LSB:New({
- varPrefix = "MYADDON",
- onChanged = function()
+local lsb = LSB.New({
+ name = "My Addon",
+ onChanged = function(ctx)
MyAddon:ApplySettings()
end,
-})
-
-SB.GetRoot("My Addon"):Register({
sections = {
{
key = "general",
name = "General",
- rows = {
- {
- type = "checkbox",
- get = function()
- return MyStore.enabled
- end,
- set = function(value)
- MyStore.enabled = value
- end,
- key = "enabled",
- default = true,
- name = "Enable",
- },
+ pages = {
{
- type = "input",
- get = function()
- return MyStore.searchText or ""
- end,
- set = function(value)
- MyStore.searchText = value
- end,
- key = "searchText",
- default = "",
- name = "Search",
+ key = "main",
+ rows = {
+ {
+ type = "checkbox",
+ get = function()
+ return MyStore.enabled
+ end,
+ set = function(value)
+ MyStore.enabled = value
+ end,
+ key = "enabled",
+ default = true,
+ name = "Enable",
+ },
+ {
+ type = "input",
+ get = function()
+ return MyStore.searchText or ""
+ end,
+ set = function(value)
+ MyStore.searchText = value
+ end,
+ key = "searchText",
+ default = "",
+ name = "Search",
+ },
+ },
},
},
},
@@ -126,10 +122,10 @@ SB.GetRoot("My Addon"):Register({
## Good defaults for public addons
-- Keep `varPrefix` short and unique.
-- Point `getStore()` and `getDefaults()` at live tables.
+- Pick a stable `name`; the library derives its internal variable prefix from that.
+- Point `store` and `defaults` at live tables.
- Keep `onChanged` fast; use it to refresh UI, not rebuild the world.
- Use composites for repeated patterns like borders, font overrides, and positioning.
- Prefer declarative root registration for large standard settings pages.
-- Store registered page handles through `onRegistered(page)` and call `page:Refresh()` for async or transient redraws.
-- Reach for `SB.Custom(...)` or `SB.EmbedCanvas(...)` only when built-ins like `input`, `list`, and `sectionList` stop fitting.
+- Look up registered page handles with `lsb:GetRootPage()` or `lsb:GetPage(...)`, then call `page:Refresh()` for async or transient redraws.
+- Reach for `type = "custom"` or `type = "canvas"` only when built-ins like `input`, `list`, and `sectionList` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index a4de6362..d6c0fdbc 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -10,17 +10,17 @@
## Controls do not save values
-Check the `PathAdapter` first.
+Check the path binding config first.
-- `getStore()` must return the live writable table.
-- `getDefaults()` should return the matching defaults table.
+- `store` must point at the live writable table.
+- `defaults` should point at the matching defaults table.
- In handler mode, verify both `get` and `set` are present.
## Path mode errors immediately
Common causes:
-- you created the builder without `pathAdapter`,
+- you created the builder without `store` / `defaults`,
- a spec mixes `path` with `get` / `set`,
- handler mode is missing `key`.
@@ -28,7 +28,8 @@ Common causes:
Usually one of these:
-- you never called `SB.GetRoot("My Addon"):Register({ ... })`,
+- you created `LSB.New({ name = "My Addon", ... })` without a `page` or `sections` tree,
+- you created `LSB.New({ ... })` without `page` or `sections`,
- your registered root page or section page ended up with no visible rows,
- a `hidden` predicate is always returning `true`,
- a `custom` template was never loaded from XML.
@@ -39,7 +40,6 @@ Check modifier predicates:
- `disabled = function() ... end`
- `hidden = function() ... end`
-- `parent` + `parentCheck`
Remember these are reactive and will be re-evaluated after setting changes.
@@ -49,9 +49,6 @@ Check these pieces:
- `resolveText(...)` must return a string or `nil`,
- `debounce` delays preview updates intentionally,
-- `watch = { ... }` uses sibling spec identifiers / paths, not display labels,
-- `watchVariables` expects already-resolved proxy-setting variable names,
-- watched settings must come from the same builder instance so callback handles can observe them.
If you just need raw text entry with no secondary preview, omit `resolveText` entirely.
@@ -97,14 +94,14 @@ Canvas layout spacing is configurable for older `CreateCanvasLayout(...)` pages.
Use:
```lua
-SB.SetCanvasLayoutDefaults({ elementHeight = 28 })
+LSBDeprecated.SetCanvasLayoutDefaults({ elementHeight = 28 })
```
or per layout:
```lua
-local layout = SB.CreateCanvasLayout("My Page")
-SB.ConfigureCanvasLayout(layout, { labelX = 40 })
+local layout = LSBDeprecated.CreateCanvasLayout("My Page")
+LSBDeprecated.ConfigureCanvasLayout(layout, { labelX = 40 })
```
If Blizzard adjusts Settings panel spacing in a major patch, this is the intended escape hatch.
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index c5f4a658..5b25681f 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -1487,6 +1487,9 @@ function TestHelpers.SetupOptionsEnv(profile, defaults)
ns.CloneValue = deepClone
ns.Runtime = ns.Runtime or {}
ns.Runtime.ScheduleLayoutUpdate = function() end
+ ns.GetGlobalConfig = function()
+ return mod.db.profile and mod.db.profile.global
+ end
ns.IsDeathKnight = function()
local _, classToken = UnitClass("player")
return classToken == "DEATHKNIGHT"
@@ -1515,7 +1518,7 @@ function TestHelpers.RegisterSettingsTree(SB, spec, rootName)
error(("RegisterSettingsTree: root already exists with name '%s'"):format(tostring(SB.name)))
end
- SB:Register(spec)
+ SB:_registerTree(spec)
return SB
end
@@ -1534,9 +1537,9 @@ function TestHelpers.RegisterSectionSpec(SB, sectionSpec, rootName)
if section then
if sectionSpec.pages then
local firstPage = sectionSpec.pages[1]
- page = firstPage and section:GetPage(firstPage.key) or nil
+ page = firstPage and root:GetPage(sectionSpec.key, firstPage.key) or nil
else
- page = section:GetPage(sectionSpec.pageKey or "main")
+ page = root:GetPage(sectionSpec.key, sectionSpec.pageKey or "main")
end
end
@@ -1551,7 +1554,7 @@ end
--- @return table|nil page Registered root page handle
function TestHelpers.RegisterRootPageSpec(SB, pageSpec, rootName)
local root = TestHelpers.RegisterSettingsTree(SB, { page = pageSpec }, rootName)
- return root, root:GetPage(pageSpec.key)
+ return root, root:GetRootPage()
end
--- Collect all proxy settings created during a function call.
diff --git a/Tests/UI/About_spec.lua b/Tests/UI/About_spec.lua
index 4aa151fc..4901eff5 100644
--- a/Tests/UI/About_spec.lua
+++ b/Tests/UI/About_spec.lua
@@ -45,9 +45,14 @@ describe("About section", function()
end
end
+ local function getInitializerData(init)
+ return init and (init._lsbData or (init.GetData and init:GetData()) or init.data) or nil
+ end
+
local function findInfoRow(layout, name)
return findInitializer(layout, function(init)
- return init._template == SB.INFOROW_TEMPLATE and init.data.name == name
+ local data = getInitializerData(init)
+ return data and data._lsbKind == "infoRow" and data.name == name
end)
end
@@ -71,24 +76,28 @@ describe("About section", function()
it("creates Author info row with sparkle text", function()
local init = findInfoRow(rootLayout, "Author")
assert.is_not_nil(init, "expected Author info row")
- assert.are.equal("<>", type(init.data.value) == "function" and init.data.value() or init.data.value)
+ local data = getInitializerData(init)
+ assert.are.equal("<>", type(data.value) == "function" and data.value() or data.value)
end)
it("creates Contributors info row", function()
local init = findInfoRow(rootLayout, "Contributors")
assert.is_not_nil(init, "expected Contributors info row")
- assert.are.equal("kayti-wow", type(init.data.value) == "function" and init.data.value() or init.data.value)
+ local data = getInitializerData(init)
+ assert.are.equal("kayti-wow", type(data.value) == "function" and data.value() or data.value)
end)
it("creates Version info row with leading v stripped", function()
local init = findInfoRow(rootLayout, "Version")
assert.is_not_nil(init, "expected Version info row")
- assert.are.equal("1.2.3-test", type(init.data.value) == "function" and init.data.value() or init.data.value)
+ local data = getInitializerData(init)
+ assert.are.equal("1.2.3-test", type(data.value) == "function" and data.value() or data.value)
end)
it("includes Links subheader", function()
local init = findInitializer(rootLayout, function(i)
- return i._template == SB.SUBHEADER_TEMPLATE and i.data.name == "Links"
+ local data = getInitializerData(i)
+ return data and data._lsbKind == "subheader" and data.name == "Links"
end)
assert.is_not_nil(init, "expected Links subheader")
end)
@@ -149,7 +158,8 @@ describe("About section", function()
local init = findInfoRow(freshRootLayout, "Version")
assert.is_not_nil(init, "expected Version info row")
- assert.are.equal("Unknown", type(init.data.value) == "function" and init.data.value() or init.data.value)
+ local data = getInitializerData(init)
+ assert.are.equal("Unknown", type(data.value) == "function" and data.value() or data.value)
end)
end)
end)
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 9c70d7e5..87416061 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -334,8 +334,8 @@ describe("BuffBarsOptions", function()
end,
}
- if spellColorsSpec.onRegistered then
- spellColorsSpec.onRegistered(fakePage)
+ if spellColorsSpec.SetRegisteredPage then
+ spellColorsSpec.SetRegisteredPage(fakePage)
end
return spellColorsSpec, refreshCalls
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 0cb1af6b..35e0e2db 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -43,6 +43,7 @@ describe("ExtraIconsOptions data helpers", function()
CreateModuleEnabledHandler = function() return function() end end,
MakeConfirmDialog = function() return {} end,
}
+ TestHelpers.LoadChunk("UI/ExtraIconsOptionsUtil.lua", "ExtraIconsOptionsUtil")(nil, ns)
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ExtraIconsOptions = ns.ExtraIconsOptions
end)
@@ -840,10 +841,13 @@ describe("ExtraIconsOptions settings page", function()
previewCalls[#previewCalls + 1] = active
end
+ TestHelpers.LoadChunk("UI/ExtraIconsOptionsUtil.lua", "ExtraIconsOptionsUtil")(nil, ns)
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
- capturedPage = ns.ExtraIconsOptions
+ capturedPage = ns.ExtraIconsOptions.pages[1]
local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
registeredPage = page
+ ns.ExtraIconsOptionsUtil.SetRegisteredPage(page)
+ ns.ExtraIconsOptionsUtil.EnsureItemLoadFrame()
registeredPage.Refresh = function()
refreshCalls[#refreshCalls + 1] = registeredPage._category
end
diff --git a/Tests/UI/LayoutOptions_spec.lua b/Tests/UI/LayoutOptions_spec.lua
index a18f5d28..cd7f421f 100644
--- a/Tests/UI/LayoutOptions_spec.lua
+++ b/Tests/UI/LayoutOptions_spec.lua
@@ -31,7 +31,7 @@ describe("LayoutOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/LayoutOptions.lua", "LayoutOptions")(nil, ns)
TestHelpers.RegisterSectionSpec(SB, ns.LayoutOptions)
- capturedPage = ns.LayoutOptions
+ capturedPage = ns.LayoutOptions.pages[1]
end)
end)
diff --git a/Tests/UI/OptionsSections_spec.lua b/Tests/UI/OptionsSections_spec.lua
index 2afdd4c4..cb336c64 100644
--- a/Tests/UI/OptionsSections_spec.lua
+++ b/Tests/UI/OptionsSections_spec.lua
@@ -79,7 +79,12 @@ describe("Options root assembly", function()
return {
key = key,
name = name,
- rows = {},
+ pages = {
+ {
+ key = "main",
+ rows = {},
+ },
+ },
}
end
@@ -98,7 +103,7 @@ describe("Options root assembly", function()
assert.is_table(ns.Settings)
assert.are.equal(ns.L["ADDON_NAME"], ns.Settings.name)
- assert.is_not_nil(ns.Settings:GetPage("about"))
+ assert.is_not_nil(ns.Settings:GetRootPage())
for _, key in ipairs({
"general",
@@ -125,14 +130,16 @@ describe("Options root assembly", function()
assert.are.equal("general", ns.GeneralOptions.key)
assert.are.equal(ns.L["GENERAL"], ns.GeneralOptions.name)
assert.are.equal("global", ns.GeneralOptions.path)
- assert.are.equal(16, #ns.GeneralOptions.rows)
- assert.are.equal("header", ns.GeneralOptions.rows[1].type)
- assert.are.equal("slider", ns.GeneralOptions.rows[16].type)
+ assert.are.equal(1, #ns.GeneralOptions.pages)
+ assert.are.equal(16, #ns.GeneralOptions.pages[1].rows)
+ assert.are.equal("header", ns.GeneralOptions.pages[1].rows[1].type)
+ assert.are.equal("slider", ns.GeneralOptions.pages[1].rows[16].type)
assert.are.equal("advancedOptions", ns.AdvancedOptions.key)
assert.are.equal(ns.L["ADVANCED_OPTIONS"], ns.AdvancedOptions.name)
assert.are.equal("global", ns.AdvancedOptions.path)
- assert.are.equal(7, #ns.AdvancedOptions.rows)
- assert.are.equal("button", ns.AdvancedOptions.rows[5].type)
+ assert.are.equal(1, #ns.AdvancedOptions.pages)
+ assert.are.equal(7, #ns.AdvancedOptions.pages[1].rows)
+ assert.are.equal("button", ns.AdvancedOptions.pages[1].rows[5].type)
end)
end)
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index f2844d5e..5f51198e 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -97,7 +97,7 @@ describe("OptionUtil", function()
local rows = ns.AboutPage.rows
assert.is_table(registeredPage)
- assert.are.equal(ns.L["ADDON_NAME"], registeredPage:GetID())
+ assert.are.equal(ns.L["ADDON_NAME"], registeredPage:GetId())
assert.are.equal(6, #rows)
assert.are.equal("info", rows[1].type)
assert.are.equal("info", rows[2].type)
@@ -126,7 +126,7 @@ describe("OptionUtil", function()
end
local handler = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar")
- handler(true)
+ handler({}, true)
assert.are.equal("PowerBar", enabledModule)
end)
@@ -138,7 +138,7 @@ describe("OptionUtil", function()
end
local handler = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar")
- handler(false)
+ handler({}, false)
assert.are.equal("PowerBar", disabledModule)
end)
@@ -150,7 +150,7 @@ describe("OptionUtil", function()
end
local handler = ns.OptionUtil.CreateModuleEnabledHandler("PowerBar")
- handler(false)
+ handler({}, false)
assert.is_false(reloadCalled)
end)
@@ -163,7 +163,7 @@ describe("OptionUtil", function()
end
local handler = ns.OptionUtil.CreateModuleEnabledHandler("BuffBars", "Reload?")
- handler(true)
+ handler({}, true)
assert.are.equal("BuffBars", enabledModule)
end)
@@ -181,7 +181,7 @@ describe("OptionUtil", function()
}
local handler = ns.OptionUtil.CreateModuleEnabledHandler("BuffBars", "Reload now?")
- handler(false, setting)
+ handler({ setting = setting }, false)
assert.is_true(revertedValue)
assert.are.equal("Reload now?", reloadMessage)
@@ -192,7 +192,7 @@ describe("OptionUtil", function()
ns.Addon.ConfirmReloadUI = function() end
local handler = ns.OptionUtil.CreateModuleEnabledHandler("BuffBars", "Reload now?")
- handler(false)
+ handler({}, false)
assert.is_true(ns.Addon.db.profile.buffBars.enabled)
end)
@@ -209,7 +209,7 @@ describe("OptionUtil", function()
local setting = { SetValueNoCallback = function() end }
local handler = ns.OptionUtil.CreateModuleEnabledHandler("BuffBars", "Reload now?")
- handler(false, setting)
+ handler({ setting = setting }, false)
assert.is_function(capturedCallback)
capturedCallback()
@@ -354,7 +354,12 @@ describe("OptionUtil", function()
return {
key = key,
name = name,
- rows = {},
+ pages = {
+ {
+ key = "main",
+ rows = {},
+ },
+ },
}
end
@@ -369,8 +374,8 @@ describe("OptionUtil", function()
ns.AdvancedOptions = placeholderSection("advancedOptions", ns.L["ADVANCED_OPTIONS"])
optionsModule:OnInitialize()
- generalCategory = ns.Settings:GetSection("general"):GetPage("main")._category
- profileCategory = ns.Settings:GetSection("profile"):GetPage("main")._category
+ generalCategory = ns.Settings:GetPage("general", "main")._category
+ profileCategory = ns.Settings:GetPage("profile", "main")._category
end)
it("opens General when no ECM page has been visited yet", function()
diff --git a/Tests/UI/PowerBarOptions_spec.lua b/Tests/UI/PowerBarOptions_spec.lua
index 17c34b6b..85d6bb28 100644
--- a/Tests/UI/PowerBarOptions_spec.lua
+++ b/Tests/UI/PowerBarOptions_spec.lua
@@ -165,8 +165,8 @@ describe("PowerBarOptions getters/setters/defaults", function()
TestHelpers.LoadChunk("UI/PowerBarOptions.lua", "PowerBarOptions")(nil, ns2)
local _, section = TestHelpers.RegisterSectionSpec(SB2, ns2.PowerBarOptions)
- local powerBarSectionCategory = section:GetPage("main")._category._parent
- local tickMarksCategory = section:GetPage("tickMarks")._category
+ local powerBarSectionCategory = SB2:GetPage(section.key, "main")._category._parent
+ local tickMarksCategory = SB2:GetPage(section.key, "tickMarks")._category
assert.is_not_nil(powerBarSectionCategory)
assert.is_not_nil(tickMarksCategory)
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index f1807123..fc43fd6f 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -40,8 +40,8 @@ describe("PowerBarTickMarksOptions", function()
end,
}
- if captured.onRegistered then
- captured.onRegistered(fakePage)
+ if captured.SetRegisteredPage then
+ captured.SetRegisteredPage(fakePage)
end
return captured, refreshCalls, fakePage
@@ -115,7 +115,7 @@ describe("PowerBarTickMarksOptions", function()
getRow(captured, "defaultWidth").set(3)
getRow(captured, "defaultColor").set(defaultColor)
- getRow(captured, "addTick").onClick(fakePage)
+ getRow(captured, "addTick").onClick({ page = fakePage })
local items = tickCollection.items()
assert.are.equal(1, #items)
diff --git a/Tests/UI/ProfileOptions_spec.lua b/Tests/UI/ProfileOptions_spec.lua
index ae9c9485..681241ca 100644
--- a/Tests/UI/ProfileOptions_spec.lua
+++ b/Tests/UI/ProfileOptions_spec.lua
@@ -38,7 +38,7 @@ describe("ProfileOptions getters/setters/defaults", function()
profileCategory = page._category
end)
refreshCalls = {}
- local page = assert(SB:GetSection("profile"):GetPage("main"))
+ local page = assert(SB:GetPage("profile", "main"))
page.Refresh = function()
refreshCalls[#refreshCalls + 1] = profileCategory
end
diff --git a/Tests/UI/ResourceBarOptions_spec.lua b/Tests/UI/ResourceBarOptions_spec.lua
index 6fca7caa..fc115ea0 100644
--- a/Tests/UI/ResourceBarOptions_spec.lua
+++ b/Tests/UI/ResourceBarOptions_spec.lua
@@ -25,7 +25,7 @@ describe("ResourceBarOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/ResourceBarOptions.lua", "ResourceBarOptions")(nil, ns)
TestHelpers.RegisterSectionSpec(SB, ns.ResourceBarOptions)
- capturedPage = ns.ResourceBarOptions
+ capturedPage = ns.ResourceBarOptions.pages[1]
end)
end)
diff --git a/Tests/UI/RuneBarOptions_spec.lua b/Tests/UI/RuneBarOptions_spec.lua
index a13adf28..0620af0e 100644
--- a/Tests/UI/RuneBarOptions_spec.lua
+++ b/Tests/UI/RuneBarOptions_spec.lua
@@ -29,7 +29,7 @@ describe("RuneBarOptions getters/setters/defaults", function()
settings = TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/RuneBarOptions.lua", "RuneBarOptions")(nil, ns)
TestHelpers.RegisterSectionSpec(SB, ns.RuneBarOptions)
- capturedPage = ns.RuneBarOptions
+ capturedPage = ns.RuneBarOptions.pages[1]
end)
end)
@@ -103,9 +103,9 @@ describe("RuneBarOptions getters/setters/defaults", function()
assert.is_nil(settings["ECM_runeBar_anchorMode"])
end)
it("adds an inline layout button row to the page", function()
- assert.are.equal("button", capturedPage.rows[3].type)
- assert.are.equal(ns.L["LAYOUT_SUBCATEGORY"], capturedPage.rows[3].name)
- assert.are.equal(ns.L["LAYOUT_PAGE_MOVED_BUTTON_TEXT"], capturedPage.rows[3].buttonText)
+ assert.are.equal("button", capturedPage.rows[2].type)
+ assert.are.equal(ns.L["LAYOUT_SUBCATEGORY"], capturedPage.rows[2].name)
+ assert.are.equal(ns.L["LAYOUT_PAGE_MOVED_BUTTON_TEXT"], capturedPage.rows[2].buttonText)
end)
end)
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index 3796d2bf..ae48b859 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -178,6 +178,9 @@ end
local function createSpellColorPage(subcatName)
local registeredPage
+ local function setRegisteredPage(page)
+ registeredPage = page
+ end
local function refreshPage()
if registeredPage then
@@ -293,9 +296,6 @@ local function createSpellColorPage(subcatName)
local pageSpec = {
key = "spellColors",
name = subcatName,
- onRegistered = function(page)
- registeredPage = page
- end,
rows = {
{
id = "spellColorsPageActions",
@@ -375,6 +375,7 @@ local function createSpellColorPage(subcatName)
},
},
}
+ pageSpec.SetRegisteredPage = setRegisteredPage
return pageSpec
end
@@ -406,7 +407,7 @@ BuffBarsOptions.pages = {
type = "checkbox",
path = "enabled",
name = L["ENABLE_AURA_BARS"],
- desc = L["ENABLE_AURA_BARS_DESC"],
+ tooltip = L["ENABLE_AURA_BARS_DESC"],
onSet = ns.OptionUtil.CreateModuleEnabledHandler("BuffBars", L["DISABLE_AURA_BARS_RELOAD"]),
},
@@ -440,7 +441,7 @@ BuffBarsOptions.pages = {
type = "slider",
path = "height",
name = L["HEIGHT_OVERRIDE"],
- desc = L["HEIGHT_OVERRIDE_DESC"],
+ tooltip = L["HEIGHT_OVERRIDE_DESC"],
min = 0,
max = 40,
step = 1,
@@ -455,15 +456,26 @@ BuffBarsOptions.pages = {
type = "slider",
path = "verticalSpacing",
name = L["AURA_VERTICAL_SPACING"],
- desc = L["AURA_VERTICAL_SPACING_DESC"],
+ tooltip = L["AURA_VERTICAL_SPACING_DESC"],
min = 0,
max = 20,
step = 1,
disabled = isDisabled,
getTransform = defaultZero,
},
- { id = "fontOverride", type = "fontOverride", disabled = isDisabled },
+ (function()
+ local row = ns.OptionUtil.CreateFontOverrideRow(isDisabled)
+ row.id = "fontOverride"
+ return row
+ end)(),
},
},
createSpellColorPage(L["SPELL_COLORS_SUBCAT"]),
}
+
+function BuffBarsOptions.SetSpellColorsPage(page)
+ local spellColorsPage = BuffBarsOptions.pages[2]
+ if spellColorsPage and spellColorsPage.SetRegisteredPage then
+ spellColorsPage.SetRegisteredPage(page)
+ end
+end
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index f9cfbb48..4c72e29e 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -13,26 +13,23 @@ local Util = assert(ns.ExtraIconsOptionsUtil, "ExtraIconsOptionsUtil missing")
ExtraIconsOptions.key = "extraIcons"
ExtraIconsOptions.name = L["EXTRA_ICONS"]
-function ExtraIconsOptions.onShow()
- ns.Runtime.SetLayoutPreview(true)
-end
-
-function ExtraIconsOptions.onHide()
- ns.Runtime.SetLayoutPreview(false)
-end
-
-function ExtraIconsOptions.onRegistered(page)
- Util.SetRegisteredPage(page)
- Util.EnsureItemLoadFrame()
-end
-
-ExtraIconsOptions.rows = {
+ExtraIconsOptions.pages = {
+ {
+ key = "main",
+ onShow = function()
+ ns.Runtime.SetLayoutPreview(true)
+ Util.EnsureItemLoadFrame()
+ end,
+ onHide = function()
+ ns.Runtime.SetLayoutPreview(false)
+ end,
+ rows = {
{
id = "enabled", type = "checkbox", path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"], desc = L["ENABLE_EXTRA_ICONS_DESC"],
- onSet = function(value, _, page)
- ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(value)
- page:Refresh()
+ name = L["ENABLE_EXTRA_ICONS"], tooltip = L["ENABLE_EXTRA_ICONS_DESC"],
+ onSet = function(ctx, value)
+ ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(ctx, value)
+ ctx.page:Refresh()
end,
},
{
@@ -46,6 +43,8 @@ ExtraIconsOptions.rows = {
sections = Util.BuildSections,
onDefault = Util.ResetToDefaults,
},
+ },
+ },
}
ns.ExtraIconsOptions = ExtraIconsOptions
diff --git a/UI/ExtraIconsOptionsUtil.lua b/UI/ExtraIconsOptionsUtil.lua
index a034a6af..ba5d9e33 100644
--- a/UI/ExtraIconsOptionsUtil.lua
+++ b/UI/ExtraIconsOptionsUtil.lua
@@ -24,7 +24,6 @@ local VIEWER_SHORT_LABELS = {
}
local ACTION_BUTTON_TEXTURE_BASE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\"
-local SPELL_API = type(C_Spell) == "table" and C_Spell or nil
local function makeTexturePair(name)
return { normal = ACTION_BUTTON_TEXTURE_BASE .. name .. "_normal", pushed = ACTION_BUTTON_TEXTURE_BASE .. name .. "_down" }
@@ -95,12 +94,18 @@ local function getViewerShortLabel(viewerKey)
return VIEWER_SHORT_LABELS[viewerKey]
end
+local function getSpellAPI()
+ return type(C_Spell) == "table" and C_Spell or nil
+end
+
local function getSpellName(spellId)
- return spellId and SPELL_API and SPELL_API.GetSpellName and SPELL_API.GetSpellName(spellId) or nil
+ local api = getSpellAPI()
+ return spellId and api and api.GetSpellName and api.GetSpellName(spellId) or nil
end
local function getSpellTexture(spellId)
- return spellId and SPELL_API and SPELL_API.GetSpellTexture and SPELL_API.GetSpellTexture(spellId) or nil
+ local api = getSpellAPI()
+ return spellId and api and api.GetSpellTexture and api.GetSpellTexture(spellId) or nil
end
local function isDisabledBuiltinEntry(entry)
diff --git a/UI/GeneralOptions.lua b/UI/GeneralOptions.lua
index e4e952a3..f4eab4be 100644
--- a/UI/GeneralOptions.lua
+++ b/UI/GeneralOptions.lua
@@ -5,59 +5,68 @@
local _, ns = ...
local L = ns.L
local LSMW = LibStub("LibLSMSettingsWidgets-1.0")
+local function isFadeDisabled()
+ local gc = ns.GetGlobalConfig and ns.GetGlobalConfig() or nil
+ local fade = gc and gc.outOfCombatFade
+ return not (fade and fade.enabled)
+end
+
local GeneralOptions = {
key = "general",
name = L["GENERAL"],
path = "global",
- rows = {
+ pages = {
+ {
+ key = "main",
+ rows = {
-- Visibility
{ type = "header", name = L["VISIBILITY"] },
{
type = "checkbox",
path = "hideWhenMounted",
name = L["HIDE_WHEN_MOUNTED"],
- desc = L["HIDE_WHEN_MOUNTED_DESC"],
+ tooltip = L["HIDE_WHEN_MOUNTED_DESC"],
},
{
type = "checkbox",
path = "hideOutOfCombatInRestAreas",
name = L["HIDE_IN_REST_AREAS"],
- desc = L["HIDE_IN_REST_AREAS_DESC"],
+ tooltip = L["HIDE_IN_REST_AREAS_DESC"],
},
{
id = "fade",
type = "checkbox",
path = "global.outOfCombatFade.enabled",
name = L["FADE_OUT_OF_COMBAT"],
- desc = L["FADE_OUT_OF_COMBAT_DESC"],
+ tooltip = L["FADE_OUT_OF_COMBAT_DESC"],
},
{
type = "slider",
path = "global.outOfCombatFade.opacity",
name = L["OUT_OF_COMBAT_OPACITY"],
- desc = L["OUT_OF_COMBAT_OPACITY_DESC"],
+ tooltip = L["OUT_OF_COMBAT_OPACITY_DESC"],
min = 0,
max = 100,
step = 5,
- parent = "fade",
+ disabled = isFadeDisabled,
},
{
type = "checkbox",
path = "global.outOfCombatFade.exceptInInstance",
name = L["EXCEPT_INSIDE_INSTANCES"],
- parent = "fade",
+ disabled = isFadeDisabled,
},
{
type = "checkbox",
path = "global.outOfCombatFade.exceptIfTargetCanBeAttacked",
name = L["EXCEPT_TARGET_HOSTILE"],
- parent = "fade",
+ disabled = isFadeDisabled,
},
{
type = "checkbox",
path = "global.outOfCombatFade.exceptIfTargetCanBeHelped",
name = L["EXCEPT_TARGET_FRIENDLY"],
- parent = "fade",
+ disabled = isFadeDisabled,
},
-- Appearance
@@ -66,14 +75,14 @@ local GeneralOptions = {
type = "custom",
path = "texture",
name = L["BAR_TEXTURE"],
- desc = L["BAR_TEXTURE_DESC"],
+ tooltip = L["BAR_TEXTURE_DESC"],
template = LSMW.TEXTURE_PICKER_TEMPLATE,
},
{
type = "custom",
path = "font",
name = L["FONT"],
- desc = L["FONT_DESC"],
+ tooltip = L["FONT_DESC"],
template = LSMW.FONT_PICKER_TEMPLATE,
},
{
@@ -102,7 +111,7 @@ local GeneralOptions = {
type = "checkbox",
path = "fontShadow",
name = L["FONT_SHADOW"],
- desc = L["FONT_SHADOW_DESC"],
+ tooltip = L["FONT_SHADOW_DESC"],
},
-- Sizing
@@ -111,11 +120,13 @@ local GeneralOptions = {
type = "slider",
path = "barHeight",
name = L["BAR_HEIGHT"],
- desc = L["BAR_HEIGHT_DESC"],
+ tooltip = L["BAR_HEIGHT_DESC"],
min = 10,
max = 40,
step = 1,
},
+ },
+ },
},
}
ns.GeneralOptions = GeneralOptions
@@ -124,19 +135,22 @@ local AdvancedOptions = {
key = "advancedOptions",
name = L["ADVANCED_OPTIONS"],
path = "global",
- rows = {
+ pages = {
+ {
+ key = "main",
+ rows = {
{ type = "header", name = L["TROUBLESHOOTING"] },
{
type = "checkbox",
path = "debug",
name = L["DEBUG_MODE"],
- desc = L["DEBUG_MODE_DESC"],
+ tooltip = L["DEBUG_MODE_DESC"],
},
{
type = "checkbox",
path = "debugToChat",
name = L["DEBUG_TO_CHAT"],
- desc = L["DEBUG_TO_CHAT_DESC"],
+ tooltip = L["DEBUG_TO_CHAT_DESC"],
disabled = function()
local gc = ns.GetGlobalConfig()
return not (gc and gc.debug)
@@ -159,11 +173,13 @@ local AdvancedOptions = {
type = "slider",
path = "updateFrequency",
name = L["UPDATE_FREQUENCY"],
- desc = L["UPDATE_FREQUENCY_DESC"],
+ tooltip = L["UPDATE_FREQUENCY_DESC"],
min = 0.04,
max = 0.5,
step = 0.02,
},
+ },
+ },
},
}
ns.AdvancedOptions = AdvancedOptions
diff --git a/UI/LayoutOptions.lua b/UI/LayoutOptions.lua
index 6e807039..2682a3f6 100644
--- a/UI/LayoutOptions.lua
+++ b/UI/LayoutOptions.lua
@@ -14,7 +14,7 @@ local function createAnchorModeSpec(name, path, disabled)
type = "dropdown",
path = path,
name = name,
- desc = L["POSITION_MODE_DESC"],
+ tooltip = L["POSITION_MODE_DESC"],
values = {
[C.ANCHORMODE_CHAIN] = L["POSITION_MODE_ATTACHED"],
[C.ANCHORMODE_DETACHED] = L["POSITION_MODE_DETACHED"],
@@ -53,7 +53,7 @@ local rows = {
type = "slider",
path = "global.offsetY",
name = L["VERTICAL_OFFSET"],
- desc = L["VERTICAL_OFFSET_DESC"],
+ tooltip = L["VERTICAL_OFFSET_DESC"],
min = 0,
max = 20,
step = 1,
@@ -62,7 +62,7 @@ local rows = {
type = "slider",
path = "global.moduleSpacing",
name = L["VERTICAL_SPACING"],
- desc = L["VERTICAL_SPACING_DESC"],
+ tooltip = L["VERTICAL_SPACING_DESC"],
min = 0,
max = 20,
step = 1,
@@ -72,7 +72,7 @@ local rows = {
type = "dropdown",
path = "global.moduleGrowDirection",
name = L["GROW_DIRECTION"],
- desc = L["GROW_DIRECTION_ATTACHED_DESC"],
+ tooltip = L["GROW_DIRECTION_ATTACHED_DESC"],
values = {
[C.GROW_DIRECTION_DOWN] = L["DOWN"],
[C.GROW_DIRECTION_UP] = L["UP"],
@@ -88,10 +88,15 @@ end
LayoutOptions.key = "layout"
LayoutOptions.name = L["LAYOUT_SUBCATEGORY"]
LayoutOptions.path = ""
-LayoutOptions.onShow = function()
- ns.Runtime.SetLayoutPreview(true)
-end
-LayoutOptions.onHide = function()
- ns.Runtime.SetLayoutPreview(false)
-end
-LayoutOptions.rows = rows
+LayoutOptions.pages = {
+ {
+ key = "main",
+ onShow = function()
+ ns.Runtime.SetLayoutPreview(true)
+ end,
+ onHide = function()
+ ns.Runtime.SetLayoutPreview(false)
+ end,
+ rows = rows,
+ },
+}
diff --git a/UI/OptionUtil.lua b/UI/OptionUtil.lua
index 8a10268d..bb12839f 100644
--- a/UI/OptionUtil.lua
+++ b/UI/OptionUtil.lua
@@ -9,6 +9,7 @@ local _, ns = ...
local C = ns.Constants
local L = ns.L
local OptionUtil = ns.OptionUtil or {}
+local LSMW = LibStub("LibLSMSettingsWidgets-1.0", true)
ns.OptionUtil = OptionUtil
@@ -126,9 +127,8 @@ end
function OptionUtil.OpenLayoutPage()
local root = ns.Settings
- local section = root and root:GetSection("layout")
- local page = section and section:GetPage("main")
- local categoryID = page and page:GetID()
+ local page = root and root:GetPage("layout", "main")
+ local categoryID = page and page:GetId()
if categoryID then
Settings.OpenToCategory(categoryID)
end
@@ -277,9 +277,10 @@ end
--- For modules that require a reload to disable, pass requiresReload with a message.
---@param moduleName string The module name (e.g., "PowerBar")
---@param requiresReload string|nil If set, disabling shows a reload confirmation with this message
----@return fun(value: boolean, setting: table)
+---@return fun(ctx: table, value: boolean)
function OptionUtil.CreateModuleEnabledHandler(moduleName, requiresReload)
- return function(value, setting)
+ return function(ctx, value)
+ local setting = ctx and ctx.setting
if value then
ns.Addon:EnableModule(moduleName)
return
@@ -300,6 +301,30 @@ function OptionUtil.CreateModuleEnabledHandler(moduleName, requiresReload)
end
end
+local function getGlobalFont()
+ local gc = ns.GetGlobalConfig and ns.GetGlobalConfig() or nil
+ return gc and gc.font
+end
+
+local function getGlobalFontSize()
+ local gc = ns.GetGlobalConfig and ns.GetGlobalConfig() or nil
+ return gc and gc.fontSize
+end
+
+function OptionUtil.CreateFontOverrideRow(isDisabled)
+ return {
+ type = "fontOverride",
+ path = "",
+ disabled = isDisabled,
+ fontValues = function()
+ return LSMW and LSMW.GetFontValues and LSMW.GetFontValues() or {}
+ end,
+ fontFallback = getGlobalFont,
+ fontSizeFallback = getGlobalFontSize,
+ fontTemplate = LSMW and LSMW.FONT_PICKER_TEMPLATE or nil,
+ }
+end
+
--- Generates standard layout and appearance rows shared by bar-type modules.
--- This is the canonical rows-array form used by declarative section/page specs.
---@param isDisabled fun(): boolean
@@ -321,13 +346,13 @@ function OptionUtil.CreateBarRows(isDisabled, options)
type = "checkbox",
path = "showText",
name = L["SHOW_TEXT"],
- desc = L["SHOW_TEXT_DESC"],
+ tooltip = L["SHOW_TEXT_DESC"],
disabled = isDisabled,
}
end
- rows[#rows + 1] = { type = "heightOverride", disabled = isDisabled }
- rows[#rows + 1] = { type = "fontOverride", disabled = isDisabled }
+ rows[#rows + 1] = { type = "heightOverride", path = "", disabled = isDisabled }
+ rows[#rows + 1] = OptionUtil.CreateFontOverrideRow(isDisabled)
if options.border ~= false then
rows[#rows + 1] = {
@@ -345,7 +370,7 @@ local function createDetachedSettingSpecs()
{
key = "detachedBarWidth",
name = L["WIDTH"],
- desc = L["DETACHED_WIDTH_DESC"],
+ tooltip = L["DETACHED_WIDTH_DESC"],
default = C.DEFAULT_BAR_WIDTH,
min = 100,
max = 600,
@@ -355,7 +380,7 @@ local function createDetachedSettingSpecs()
{
key = "detachedModuleSpacing",
name = L["SPACING"],
- desc = L["DETACHED_SPACING_DESC"],
+ tooltip = L["DETACHED_SPACING_DESC"],
default = 0,
min = 0,
max = 20,
@@ -365,7 +390,7 @@ local function createDetachedSettingSpecs()
{
key = "detachedGrowDirection",
name = L["GROW_DIRECTION"],
- desc = L["DETACHED_GROW_DIRECTION_DESC"],
+ tooltip = L["DETACHED_GROW_DIRECTION_DESC"],
default = C.GROW_DIRECTION_DOWN,
values = {
{ text = L["DOWN"], value = C.GROW_DIRECTION_DOWN },
@@ -389,7 +414,7 @@ function OptionUtil.CreateDetachedStackRows()
local row = {
path = "global." .. spec.key,
name = spec.name,
- desc = spec.desc,
+ tooltip = spec.tooltip,
getTransform = spec.default == 0 and defaultZero or OptionUtil.CreateDefaultValueTransform(spec.default),
}
diff --git a/UI/Options.lua b/UI/Options.lua
index f56d89f3..9a9a09ff 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -4,14 +4,6 @@
local _, ns = ...
local L = ns.L
-local LSMW = LibStub("LibLSMSettingsWidgets-1.0")
-
-local OU = ns.OptionUtil
-local getGlobalConfig = ns.GetGlobalConfig or function()
- local db = ns.Addon and ns.Addon.db
- local profile = db and db.profile
- return profile and profile.global
-end
local CURSEFORGE_URL = "https://www.curseforge.com/wow/addons/enhanced-cooldown-manager"
local GITHUB_URL = "https://github.com/argium/EnhancedCooldownManager"
@@ -24,36 +16,17 @@ local LSB = LibStub("LibSettingsBuilder-1.0")
ns.Settings = LSB:New({
name = L["ADDON_NAME"],
- pathAdapter = LSB.PathAdapter({
- getStore = function()
- return ns.Addon.db and ns.Addon.db.profile
- end,
- getDefaults = function()
- return ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
- end,
- getNestedValue = OU.GetNestedValue,
- setNestedValue = OU.SetNestedValue,
- }),
- varPrefix = "ECM",
- onChanged = function(spec)
- if spec.layout ~= false then
+ store = function()
+ return ns.Addon.db and ns.Addon.db.profile
+ end,
+ defaults = function()
+ return ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ end,
+ onChanged = function(ctx)
+ if ctx.spec.layout ~= false then
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
end,
- compositeDefaults = {
- FontOverrideGroup = {
- fontValues = LSMW.GetFontValues,
- fontFallback = function()
- local gc = getGlobalConfig()
- return gc and gc.font
- end,
- fontSizeFallback = function()
- local gc = getGlobalConfig()
- return gc and gc.fontSize
- end,
- fontTemplate = LSMW.FONT_PICKER_TEMPLATE,
- },
- },
})
ns.SettingsBuilder = ns.Settings
@@ -138,10 +111,9 @@ end
local function getDefaultOptionsCategoryToken()
local root = ns.Settings
- local section = root and root:GetSection("general")
- local page = section and section:GetPage("main")
+ local page = root and root:GetPage("general", "main")
if page then
- return page:GetID()
+ return page:GetId()
end
return nil
@@ -180,7 +152,7 @@ function Options:InstallCategoryTracking()
end
function Options:OnInitialize()
- ns.Settings:Register({
+ ns.Settings:_registerTree({
page = ns.AboutPage,
sections = {
ns.GeneralOptions,
@@ -195,6 +167,17 @@ function Options:OnInitialize()
},
})
+ if ns.ExtraIconsOptionsUtil then
+ ns.ExtraIconsOptionsUtil.SetRegisteredPage(ns.Settings:GetPage("extraIcons", "main"))
+ ns.ExtraIconsOptionsUtil.EnsureItemLoadFrame()
+ end
+ if ns.PowerBarTickMarksOptions and ns.PowerBarTickMarksOptions.SetRegisteredPage then
+ ns.PowerBarTickMarksOptions.SetRegisteredPage(ns.Settings:GetPage("powerBar", "tickMarks"))
+ end
+ if ns.BuffBarsOptions and ns.BuffBarsOptions.SetSpellColorsPage then
+ ns.BuffBarsOptions.SetSpellColorsPage(ns.Settings:GetPage("buffBars", "spellColors"))
+ end
+
self:InstallCategoryTracking()
end
diff --git a/UI/PowerBarOptions.lua b/UI/PowerBarOptions.lua
index b9fced6b..29104903 100644
--- a/UI/PowerBarOptions.lua
+++ b/UI/PowerBarOptions.lua
@@ -38,7 +38,7 @@ rows[#rows + 1] = {
type = "checkbox",
path = "showManaAsPercent",
name = L["SHOW_MANA_AS_PERCENT"],
- desc = L["SHOW_MANA_AS_PERCENT_DESC"],
+ tooltip = L["SHOW_MANA_AS_PERCENT_DESC"],
disabled = isDisabled,
}
rows[#rows + 1] = {
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index 88b94b53..f6ad6136 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -104,6 +104,9 @@ end
StaticPopupDialogs["ECM_CONFIRM_CLEAR_TICKS"] = ns.OptionUtil.MakeConfirmDialog(L["TICK_MARKS_CLEAR_CONFIRM"])
local registeredPage
+function PowerBarTickMarksOptions.SetRegisteredPage(page)
+ registeredPage = page
+end
local function refreshPage()
if registeredPage then
@@ -211,9 +214,6 @@ end
PowerBarTickMarksOptions.key = "tickMarks"
PowerBarTickMarksOptions.name = "Tick Marks"
-PowerBarTickMarksOptions.onRegistered = function(page)
- registeredPage = page
-end
PowerBarTickMarksOptions.rows = {
{
id = "tickMarksPageActions",
@@ -264,8 +264,8 @@ PowerBarTickMarksOptions.rows = {
set = function(color)
setDefaultColor(color)
end,
- onSet = function(_, _, page)
- page:Refresh()
+ onSet = function(ctx)
+ ctx.page:Refresh()
end,
},
{
@@ -283,8 +283,8 @@ PowerBarTickMarksOptions.rows = {
set = function(width)
setDefaultWidth(width)
end,
- onSet = function(_, _, page)
- page:Refresh()
+ onSet = function(ctx)
+ ctx.page:Refresh()
end,
},
{
@@ -292,10 +292,10 @@ PowerBarTickMarksOptions.rows = {
type = "button",
name = L["ADD_TICK_MARK"],
buttonText = L["ADD"],
- onClick = function(page)
+ onClick = function(ctx)
addTick(50, nil, nil)
scheduleUpdate()
- page:Refresh()
+ ctx.page:Refresh()
end,
},
{
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index 28fcd3d2..2eba09dc 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -139,7 +139,10 @@ local deleteProfileRow, getDeleteProfile, resetDeleteProfile = createProfilePick
ProfileOptions.key = "profile"
ProfileOptions.name = L["PROFILES"]
-ProfileOptions.rows = {
+ProfileOptions.pages = {
+ {
+ key = "main",
+ rows = {
{ type = "header", name = L["ACTIVE_PROFILE"] },
{
type = "dropdown",
@@ -161,8 +164,8 @@ ProfileOptions.rows = {
set = function(value)
ns.Addon.db:SetProfile(value)
end,
- onSet = function(_, _, page)
- page:Refresh()
+ onSet = function(ctx)
+ ctx.page:Refresh()
end,
},
{
@@ -170,11 +173,11 @@ ProfileOptions.rows = {
name = L["NEW_PROFILE"],
buttonText = L["NEW_PROFILE"],
tooltip = L["NEW_PROFILE_DESC"],
- onClick = function(page)
+ onClick = function(ctx)
StaticPopup_Show("ECM_NEW_PROFILE", nil, nil, {
onAccept = function(name)
ns.Addon.db:SetProfile(name)
- page:Refresh()
+ ctx.page:Refresh()
end,
})
end,
@@ -186,7 +189,7 @@ ProfileOptions.rows = {
name = L["COPY"],
buttonText = L["COPY"],
tooltip = L["COPY_DESC"],
- onClick = function(page)
+ onClick = function(ctx)
local profile = getCopyProfile()
if not profile or profile == "" then
return
@@ -195,7 +198,7 @@ ProfileOptions.rows = {
onAccept = function()
ns.Addon.db:CopyProfile(profile)
resetCopyProfile()
- page:Refresh()
+ ctx.page:Refresh()
end,
})
end,
@@ -206,7 +209,7 @@ ProfileOptions.rows = {
name = L["DELETE"],
buttonText = L["DELETE"],
tooltip = L["DELETE_DESC"],
- onClick = function(page)
+ onClick = function(ctx)
local profile = getDeleteProfile()
if not profile or profile == "" then
return
@@ -215,7 +218,7 @@ ProfileOptions.rows = {
onAccept = function()
ns.Addon.db:DeleteProfile(profile)
resetDeleteProfile()
- page:Refresh()
+ ctx.page:Refresh()
end,
})
end,
@@ -227,9 +230,9 @@ ProfileOptions.rows = {
buttonText = L["RESET_PROFILE_BUTTON"],
tooltip = L["RESET_PROFILE_DESC"],
confirm = L["RESET_PROFILE_CONFIRM"],
- onClick = function(page)
+ onClick = function(ctx)
ns.Addon.db:ResetProfile()
- page:Refresh()
+ ctx.page:Refresh()
end,
},
{ type = "header", name = L["IMPORT_EXPORT"] },
@@ -260,4 +263,6 @@ ProfileOptions.rows = {
ns.Addon:ShowExportDialog(exportString)
end,
},
+ },
+ },
}
diff --git a/UI/ResourceBarOptions.lua b/UI/ResourceBarOptions.lua
index d94115ba..25945d97 100644
--- a/UI/ResourceBarOptions.lua
+++ b/UI/ResourceBarOptions.lua
@@ -114,4 +114,9 @@ rows[#rows + 1] = {
ResourceBarOptions.key = "resourceBar"
ResourceBarOptions.name = L["RESOURCE_BAR"]
ResourceBarOptions.disabled = ns.IsDeathKnight
-ResourceBarOptions.rows = rows
+ResourceBarOptions.pages = {
+ {
+ key = "main",
+ rows = rows,
+ },
+}
diff --git a/UI/RuneBarOptions.lua b/UI/RuneBarOptions.lua
index 3bba920e..8528a713 100644
--- a/UI/RuneBarOptions.lua
+++ b/UI/RuneBarOptions.lua
@@ -7,15 +7,17 @@ local L = ns.L
local RuneBarOptions = {}
ns.RuneBarOptions = RuneBarOptions
local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
+local function isUseSpecColorDisabled()
+ local runeBar = ns.Addon and ns.Addon.db and ns.Addon.db.profile and ns.Addon.db.profile.runeBar
+ return isDisabled() or not (runeBar and runeBar.useSpecColor)
+end
+
+local function isSingleRuneColorDisabled()
+ local runeBar = ns.Addon and ns.Addon.db and ns.Addon.db.profile and ns.Addon.db.profile.runeBar
+ return isDisabled() or (runeBar and runeBar.useSpecColor) == true
+end
local rows = {
- {
- type = "subheader",
- name = L["DK_ONLY_WARNING"],
- condition = function()
- return not ns.IsDeathKnight()
- end,
- },
{
type = "checkbox",
path = "enabled",
@@ -28,6 +30,13 @@ for _, row in ipairs(ns.OptionUtil.CreateBarRows(isDisabled, { showText = false,
rows[#rows + 1] = row
end
+if not ns.IsDeathKnight() then
+ table.insert(rows, 1, {
+ type = "subheader",
+ name = L["DK_ONLY_WARNING"],
+ })
+end
+
rows[#rows + 1] = {
id = "colorLabel",
type = "subheader",
@@ -39,41 +48,32 @@ rows[#rows + 1] = {
type = "checkbox",
path = "useSpecColor",
name = L["USE_SPEC_COLOR"],
- desc = L["USE_SPEC_COLOR_DESC"],
- parent = "colorLabel",
+ tooltip = L["USE_SPEC_COLOR_DESC"],
disabled = isDisabled,
}
rows[#rows + 1] = {
type = "color",
path = "color",
name = L["RUNE_COLOR"],
- parent = "useSpecColor",
- parentCheck = "notChecked",
- disabled = isDisabled,
+ disabled = isSingleRuneColorDisabled,
}
rows[#rows + 1] = {
type = "color",
path = "colorBlood",
name = L["BLOOD_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
+ disabled = isUseSpecColorDisabled,
}
rows[#rows + 1] = {
type = "color",
path = "colorFrost",
name = L["FROST_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
+ disabled = isUseSpecColorDisabled,
}
rows[#rows + 1] = {
type = "color",
path = "colorUnholy",
name = L["UNHOLY_COLOR"],
- parent = "useSpecColor",
- parentCheck = "checked",
- disabled = isDisabled,
+ disabled = isUseSpecColorDisabled,
}
RuneBarOptions.key = "runeBar"
@@ -81,4 +81,9 @@ RuneBarOptions.name = L["RUNE_BAR"]
RuneBarOptions.disabled = function()
return not ns.IsDeathKnight()
end
-RuneBarOptions.rows = rows
+RuneBarOptions.pages = {
+ {
+ key = "main",
+ rows = rows,
+ },
+}
From e105e3facbb1a9cf5b7756858683d559444bf166 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 16 Apr 2026 18:00:21 +1000
Subject: [PATCH 17/53] phase 3
---
.../CompositeControls/Groups.lua | 10 +--
.../CompositeControls/Lists.lua | 8 +-
Libs/LibSettingsBuilder/Controls/Base.lua | 14 ++--
.../Controls/Collections.lua | 8 +-
Libs/LibSettingsBuilder/Controls/Rows.lua | 18 ++--
Libs/LibSettingsBuilder/Core.lua | 83 ++++++-------------
Libs/LibSettingsBuilder/Primitives/Layout.lua | 11 ++-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 63 ++++++++++++++
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 12 ++-
Libs/LibSettingsBuilder/Utility.lua | 77 ++++++++---------
10 files changed, 157 insertions(+), 147 deletions(-)
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
index bab7de3e..8b2494c6 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
@@ -8,13 +8,11 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-local BuilderMixin = lib._internal.BuilderMixin
-
local function callBuilder(builder, methodName, ...)
- return BuilderMixin[methodName](builder, ...)
+ return lib[methodName](builder, ...)
end
-function BuilderMixin:HeightOverrideSlider(sectionPath, spec)
+function lib:HeightOverrideSlider(sectionPath, spec)
spec = spec or {}
local childSpec = {
path = sectionPath .. ".height",
@@ -40,7 +38,7 @@ end
--- fontFallback function() -> string (fallback font name)
--- fontSizeFallback function() -> number (fallback font size)
--- fontTemplate string (custom template for the font picker)
-function BuilderMixin:FontOverrideGroup(sectionPath, spec)
+function lib:FontOverrideGroup(sectionPath, spec)
spec = spec or {}
local overridePath = sectionPath .. ".overrideFont"
@@ -118,7 +116,7 @@ function BuilderMixin:FontOverrideGroup(sectionPath, spec)
}
end
-function BuilderMixin:BorderGroup(borderPath, spec)
+function lib:BorderGroup(borderPath, spec)
spec = spec or {}
local enabledSpec = {
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
index 212b553b..40dd3724 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
@@ -8,8 +8,6 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-local BuilderMixin = lib._internal.BuilderMixin
-
local function buildControlList(builder, basePath, defs, spec, methodName)
local results = {}
spec = spec or {}
@@ -20,16 +18,16 @@ local function buildControlList(builder, basePath, defs, spec, methodName)
tooltip = def.tooltip,
}
builder:_propagateModifiers(childSpec, spec)
- local initializer, setting = BuilderMixin[methodName](builder, childSpec)
+ local initializer, setting = lib[methodName](builder, childSpec)
results[#results + 1] = { key = def.key, initializer = initializer, setting = setting }
end
return results
end
-function BuilderMixin:ColorPickerList(basePath, defs, spec)
+function lib:ColorPickerList(basePath, defs, spec)
return buildControlList(self, basePath, defs, spec, "Color")
end
-function BuilderMixin:CheckboxList(basePath, defs, spec)
+function lib:CheckboxList(basePath, defs, spec)
return buildControlList(self, basePath, defs, spec, "Checkbox")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index c0142975..703580bf 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -15,9 +15,7 @@ local getSettingVariable = internal.getSettingVariable
local applyInputRowEnabledState = internal.applyInputRowEnabledState
local applyInputRowFrame = internal.applyInputRowFrame
local cancelInputPreviewTimer = internal.cancelInputPreviewTimer
-local BuilderMixin = internal.BuilderMixin
-
-function BuilderMixin:Checkbox(spec)
+function lib:Checkbox(spec)
self:_validateSpecFields("checkbox", spec)
local setting, category = self:_makeProxySetting(spec, Settings.VarType.Boolean, false)
local initializer = Settings.CreateCheckbox(category, setting, spec.tooltip)
@@ -25,7 +23,7 @@ function BuilderMixin:Checkbox(spec)
return initializer, setting
end
-function BuilderMixin:Slider(spec)
+function lib:Slider(spec)
self:_validateSpecFields("slider", spec)
local setting, category = self:_makeProxySetting(spec, Settings.VarType.Number, 0)
@@ -38,7 +36,7 @@ function BuilderMixin:Slider(spec)
return initializer, setting
end
-function BuilderMixin:Dropdown(spec)
+function lib:Dropdown(spec)
self:_validateSpecFields("dropdown", spec)
local binding = self:_resolveBinding(spec)
@@ -111,7 +109,7 @@ function BuilderMixin:Dropdown(spec)
return initializer, setting
end
-function BuilderMixin:Color(spec)
+function lib:Color(spec)
self:_validateSpecFields("color", spec)
local variable = self:_makeVarName(spec)
@@ -148,7 +146,7 @@ function BuilderMixin:Color(spec)
return initializer, setting
end
-function BuilderMixin:Input(spec)
+function lib:Input(spec)
self:_validateSpecFields("input", spec)
local setting, category = self:_makeProxySetting(spec, Settings.VarType.String, "")
@@ -208,7 +206,7 @@ end
--- Creates a proxy setting backed by a custom frame template.
--- The template's Init receives initializer data containing {setting, name, tooltip}.
-function BuilderMixin:Custom(spec)
+function lib:Custom(spec)
self:_validateSpecFields("custom", spec)
assert(spec.template, "Custom: spec.template is required")
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index 79a76027..4f1df7e2 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -12,9 +12,7 @@ local internal = lib._internal
local applyCollectionFrame = internal.applyCollectionFrame
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local copyMixin = internal.copyMixin
-local BuilderMixin = internal.BuilderMixin
-
-function BuilderMixin:_createCollectionInitializer(spec, errorPrefix)
+function lib:_createCollectionInitializer(spec, errorPrefix)
assert(spec.height, errorPrefix .. ": spec.height is required")
local category = self:_resolveCategory(spec)
@@ -46,13 +44,13 @@ function BuilderMixin:_createCollectionInitializer(spec, errorPrefix)
return initializer
end
-function BuilderMixin:List(spec)
+function lib:List(spec)
assert(spec.items, "List: spec.items is required")
assert(not spec.sections, "List: spec.sections is not supported")
return self:_createCollectionInitializer(spec, "List")
end
-function BuilderMixin:SectionList(spec)
+function lib:SectionList(spec)
assert(spec.sections, "SectionList: spec.sections is required")
return self:_createCollectionInitializer(spec, "SectionList")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 120cf2f8..6468632b 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -16,9 +16,7 @@ local applySubheaderFrame = internal.applySubheaderFrame
local copyMixin = internal.copyMixin
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local hideHeaderActionButtons = internal.hideHeaderActionButtons
-local BuilderMixin = internal.BuilderMixin
-
-function BuilderMixin:_addLayoutInitializer(spec, initializer, refreshable)
+function lib:_addLayoutInitializer(spec, initializer, refreshable)
local category = self:_resolveCategory(spec)
self._layouts[category]:AddInitializer(initializer)
if refreshable then
@@ -28,7 +26,7 @@ function BuilderMixin:_addLayoutInitializer(spec, initializer, refreshable)
return initializer, category
end
-function BuilderMixin:Header(textOrSpec, category)
+function lib:Header(textOrSpec, category)
local spec = type(textOrSpec) == "table" and textOrSpec or {
name = textOrSpec,
category = category,
@@ -39,7 +37,7 @@ function BuilderMixin:Header(textOrSpec, category)
return self:_addLayoutInitializer(spec, initializer)
end
-function BuilderMixin:PageActions(spec)
+function lib:PageActions(spec)
assert(spec.actions, "PageActions: spec.actions is required")
local category = self:_resolveCategory(spec)
@@ -60,7 +58,7 @@ function BuilderMixin:PageActions(spec)
return self:_addLayoutInitializer(spec, initializer, true)
end
-function BuilderMixin:Subheader(spec)
+function lib:Subheader(spec)
local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
_lsbKind = "subheader",
name = spec.name,
@@ -68,7 +66,7 @@ function BuilderMixin:Subheader(spec)
return self:_addLayoutInitializer(spec, initializer)
end
-function BuilderMixin:InfoRow(spec)
+function lib:InfoRow(spec)
local initializer = createCustomListRowInitializer(internal.INFOROW_TEMPLATE, {
_lsbKind = "infoRow",
name = spec.name,
@@ -82,7 +80,7 @@ function BuilderMixin:InfoRow(spec)
return self:_addLayoutInitializer(spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
end
-function BuilderMixin:EmbedCanvas(canvas, height, spec)
+function lib:EmbedCanvas(canvas, height, spec)
spec = spec or {}
local modifiers = copyMixin({}, spec)
@@ -99,7 +97,7 @@ function BuilderMixin:EmbedCanvas(canvas, height, spec)
return initializer
end
-function BuilderMixin:_ensureConfirmDialog()
+function lib:_ensureConfirmDialog()
if self._confirmDialogName then
return self._confirmDialogName
end
@@ -124,7 +122,7 @@ function BuilderMixin:_ensureConfirmDialog()
return self._confirmDialogName
end
-function BuilderMixin:Button(spec)
+function lib:Button(spec)
local callbackContext = self:_createCallbackContext(spec)
local onClick = spec.onClick
if spec.confirm then
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 70d2bf5b..8878192d 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -17,8 +17,6 @@ lib._internal = lib._internal or {}
lib.LSBDeprecated = lib.LSBDeprecated or {}
local internal = lib._internal
-internal.BuilderMixin = internal.BuilderMixin or {}
-local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
internal.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
@@ -696,32 +694,32 @@ local EXTRA_FIELDS_BY_TYPE = {
custom = { template = true, varType = true },
}
-function BuilderMixin:_makeVarNameFromIdentifier(identifier)
+function lib:_makeVarNameFromIdentifier(identifier)
return self._config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
end
-function BuilderMixin:_makeVarName(spec)
+function lib:_makeVarName(spec)
local id = spec.key or spec.path
return self:_makeVarNameFromIdentifier(id)
end
-function BuilderMixin:_createCallbackContext(spec, setting)
+function lib:_createCallbackContext(spec, setting)
return {
builder = self,
category = self:_resolveCategory(spec),
key = spec.key,
- page = spec._page,
+ page = spec._page and spec._page._handle,
path = spec.path,
setting = setting,
spec = spec,
}
end
-function BuilderMixin:_resolveCategory(spec)
+function lib:_resolveCategory(spec)
return spec.category or self._currentSubcategory or self._rootCategory
end
-function BuilderMixin:_registerCategoryRefreshable(category, initializer)
+function lib:_registerCategoryRefreshable(category, initializer)
if not category or not initializer then
return
end
@@ -741,7 +739,7 @@ function BuilderMixin:_registerCategoryRefreshable(category, initializer)
refreshables[#refreshables + 1] = initializer
end
-function BuilderMixin:_postSet(spec, value, setting)
+function lib:_postSet(spec, value, setting)
local ctx = self:_createCallbackContext(spec, setting)
if spec.onSet then
spec.onSet(ctx, value)
@@ -750,7 +748,7 @@ function BuilderMixin:_postSet(spec, value, setting)
self:_reevaluateReactiveControls()
end
-function BuilderMixin:_resolveBinding(spec)
+function lib:_resolveBinding(spec)
local hasPath = spec.path ~= nil
local hasHandler = spec.get ~= nil or spec.set ~= nil
@@ -773,7 +771,7 @@ function BuilderMixin:_resolveBinding(spec)
return binding
end
-function BuilderMixin:_makeProxySetting(spec, varType, defaultFallback, binding)
+function lib:_makeProxySetting(spec, varType, defaultFallback, binding)
local variable = self:_makeVarName(spec)
local category = self:_resolveCategory(spec)
local setting
@@ -822,7 +820,7 @@ function BuilderMixin:_makeProxySetting(spec, varType, defaultFallback, binding)
return setting, category
end
-function BuilderMixin:_propagateModifiers(target, source)
+function lib:_propagateModifiers(target, source)
for _, key in ipairs(MODIFIER_KEYS) do
if target[key] == nil then
target[key] = source[key]
@@ -830,7 +828,7 @@ function BuilderMixin:_propagateModifiers(target, source)
end
end
-function BuilderMixin:_validateSpecFields(controlType, spec)
+function lib:_validateSpecFields(controlType, spec)
if not LSB_DEBUG then
return
end
@@ -855,7 +853,7 @@ function BuilderMixin:_validateSpecFields(controlType, spec)
end
end
-function BuilderMixin:_setCanvasInteractive(frame, enabled)
+function lib:_setCanvasInteractive(frame, enabled)
if frame.SetEnabled then
frame:SetEnabled(enabled)
end
@@ -870,7 +868,7 @@ function BuilderMixin:_setCanvasInteractive(frame, enabled)
end
end
-function BuilderMixin:_isParentEnabled(spec)
+function lib:_isParentEnabled(spec)
if not spec._parentInitializer then
return true
end
@@ -889,21 +887,21 @@ function BuilderMixin:_isParentEnabled(spec)
return setting:GetValue()
end
-function BuilderMixin:_isControlEnabled(spec)
+function lib:_isControlEnabled(spec)
if spec.disabled and spec.disabled() then
return false
end
return self:_isParentEnabled(spec)
end
-function BuilderMixin:_applyCanvasState(canvas, enabled)
+function lib:_applyCanvasState(canvas, enabled)
if canvas.SetAlpha then
canvas:SetAlpha(enabled and 1 or 0.5)
end
self:_setCanvasInteractive(canvas, enabled)
end
-function BuilderMixin:_reevaluateReactiveControls()
+function lib:_reevaluateReactiveControls()
local panel = SettingsPanel
if panel and panel:IsShown() then
local settingsList = panel:GetSettingsList()
@@ -924,7 +922,7 @@ function BuilderMixin:_reevaluateReactiveControls()
end
end
-function BuilderMixin:_applyEnabledState(initializer, spec)
+function lib:_applyEnabledState(initializer, spec)
local enabled = self:_isControlEnabled(spec)
if initializer.SetEnabled then
initializer:SetEnabled(enabled)
@@ -935,7 +933,7 @@ function BuilderMixin:_applyEnabledState(initializer, spec)
return enabled
end
-function BuilderMixin:_applyModifiers(initializer, spec)
+function lib:_applyModifiers(initializer, spec)
if not initializer then
return
end
@@ -964,7 +962,7 @@ function BuilderMixin:_applyModifiers(initializer, spec)
end
end
-function BuilderMixin:_colorTableToHex(tbl)
+function lib:_colorTableToHex(tbl)
if not tbl then
return "FFFFFFFF"
end
@@ -977,14 +975,14 @@ function BuilderMixin:_colorTableToHex(tbl)
)
end
-function BuilderMixin:_storeCategory(name, category, layout)
+function lib:_storeCategory(name, category, layout)
self._subcategories[name] = category
self._subcategoryNames[category] = name
self._layouts[category] = layout
return category
end
-BuilderMixin._defaultSliderFormatter = defaultSliderFormatter
+lib._defaultSliderFormatter = defaultSliderFormatter
--- Create a new LibSettingsBuilder runtime instance.
---@param config table
@@ -1021,11 +1019,9 @@ function lib.New(selfOrConfig, maybeConfig)
})
end
- local SB
- SB = setmetatable({
+ local lsb = setmetatable({
_config = config,
_adapter = adapter,
- _boundMethods = {},
_rootCategory = nil,
_rootCategoryName = nil,
_rootRegistered = nil,
@@ -1043,46 +1039,20 @@ function lib.New(selfOrConfig, maybeConfig)
_nextRootPageSequence = 0,
_nextSectionSequence = 0,
name = nil,
- }, {
- __index = function(_, key)
- local value = BuilderMixin[key]
- if type(value) ~= "function" then
- return value
- end
-
- local publicBuilderMethods = internal.publicBuilderMethods
- if publicBuilderMethods and key:sub(1, 1) ~= "_" and not publicBuilderMethods[key] then
- return nil
- end
-
- local bound = SB._boundMethods[key]
- if bound then
- return bound
- end
-
- bound = function(first, ...)
- if first == nil or first == SB then
- return value(SB, ...)
- end
- return value(SB, first, ...)
- end
- SB._boundMethods[key] = bound
- return bound
- end,
- })
+ }, { __index = lib })
if config.name ~= nil then
- SB:_initializeRoot(config.name)
+ lsb:_initializeRoot(config.name)
end
if config.page or config.sections then
- SB:_registerTree({
+ lsb:_registerTree({
page = config.page,
sections = config.sections,
})
end
- return SB
+ return lsb
end
internal.installPageLifecycleHooks = installPageLifecycleHooks
@@ -1101,7 +1071,6 @@ internal.evaluateStaticOrFunction = evaluateStaticOrFunction
internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
-lib.BuilderMixin = nil
lib.CanvasLayout = nil
lib.CanvasLayoutDefaults = nil
lib.CreateColorSwatch = nil
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index 8d7ee1bf..14bf637d 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -10,10 +10,9 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
-local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
-function BuilderMixin:_createRootCategory(name)
+function lib:_createRootCategory(name)
local category, layout = Settings.RegisterVerticalLayoutCategory(name)
self._rootCategory = category
self._rootCategoryName = name
@@ -22,14 +21,14 @@ function BuilderMixin:_createRootCategory(name)
return category
end
-function BuilderMixin:_createSubcategory(name, parentCategory)
+function lib:_createSubcategory(name, parentCategory)
local parent = parentCategory or self._rootCategory
local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
self._currentSubcategory = self:_storeCategory(name, subcategory, layout)
return subcategory
end
-function BuilderMixin:_createCanvasSubcategory(frame, name, parentCategory)
+function lib:_createCanvasSubcategory(frame, name, parentCategory)
local parent = parentCategory or self._rootCategory
local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
return self:_storeCategory(name, subcategory, layout)
@@ -42,7 +41,7 @@ end
---@param name string Subcategory display name.
---@param parentCategory? table Parent category (defaults to root).
---@return table layout CanvasLayout instance (layout.frame for the raw frame).
-function BuilderMixin:CreateCanvasLayout(name, parentCategory)
+function lib:CreateCanvasLayout(name, parentCategory)
local frame = CreateFrame("Frame", nil)
self:_createCanvasSubcategory(frame, name, parentCategory)
local metrics = copyMixin({}, internal.CanvasLayoutDefaults)
@@ -55,5 +54,5 @@ function BuilderMixin:CreateCanvasLayout(name, parentCategory)
end
Deprecated.CreateCanvasLayout = function(...)
- return BuilderMixin.CreateCanvasLayout(...)
+ return lib.CreateCanvasLayout(...)
end
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index 91f66375..f68e3026 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -235,4 +235,67 @@ describe("LibSettingsBuilder Builder", function()
assert.is_nil(page.Checkbox)
assert.is_nil(page.List)
end)
+
+ it("returns an lsb instance with all methods accessible via lib prototype", function()
+ local sb = createBuilder({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = { { type = "info", name = "Version", value = "1.0" } },
+ },
+ },
+ },
+ },
+ })
+
+ -- Public API methods accessible via __index = lib
+ assert.is_function(sb.GetSection)
+ assert.is_function(sb.GetRootPage)
+ assert.is_function(sb.GetPage)
+ assert.is_function(sb.HasCategory)
+
+ -- Control builder methods also accessible via prototype
+ assert.is_function(sb.Checkbox)
+ assert.is_function(sb.Slider)
+ assert.is_function(sb.BorderGroup)
+
+ -- Instance state is raw on the table
+ assert.is_table(rawget(sb, "_sections"))
+ assert.is_table(rawget(sb, "_layouts"))
+ end)
+
+ it("returns plain page handles with methods directly on the table", function()
+ local sb = createBuilder({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = { { type = "info", name = "Version", value = "1.0" } },
+ },
+ },
+ },
+ },
+ })
+ local page = assert(sb:GetPage("general", "main"))
+
+ -- Methods are directly on the handle, not via metatable
+ assert.is_function(rawget(page, "GetId"))
+ assert.is_function(rawget(page, "Refresh"))
+ -- _category is kept for HasCategory use
+ assert.is_not_nil(rawget(page, "_category"))
+
+ -- Internal page state is not on the handle
+ assert.is_nil(page._operations)
+ assert.is_nil(page._rowIDs)
+ assert.is_nil(page._registered)
+ assert.is_nil(page._builder)
+ assert.is_nil(page._root)
+ end)
end)
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index 09934efd..997a387e 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -54,7 +54,7 @@ describe("LibSettingsBuilder Core", function()
assert.is_table(deprecated.CanvasLayout)
end)
- it("exposes only the planned public runtime surface on builder instances", function()
+ it("exposes the full builder API on instances via lib prototype", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local sb = lsb.New({
name = "Phase 2",
@@ -79,12 +79,10 @@ describe("LibSettingsBuilder Core", function()
assert.is_function(sb.GetRootPage)
assert.is_function(sb.GetPage)
assert.is_function(sb.HasCategory)
- assert.is_nil(sb.GetRoot)
- assert.is_nil(sb.Register)
- assert.is_nil(sb.EmbedCanvas)
- assert.is_nil(sb.Checkbox)
- assert.is_nil(sb.List)
- assert.is_nil(sb.Control)
+ assert.is_function(sb.Checkbox)
+ assert.is_function(sb.List)
+ assert.is_function(sb.Control)
+ assert.is_function(sb.EmbedCanvas)
end)
it("store/defaults bindings resolve nested values and defaults", function()
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index c09a191b..7acfbd09 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -12,20 +12,8 @@ local internal = lib._internal
local copyMixin = internal.copyMixin
local installPageLifecycleHooks = internal.installPageLifecycleHooks
local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
-local BuilderMixin = internal.BuilderMixin
local Deprecated = lib.LSBDeprecated
-local PUBLIC_BUILDER_METHODS = {
- GetSection = true,
- GetRootPage = true,
- GetPage = true,
- HasCategory = true,
-}
-
-internal.publicBuilderMethods = PUBLIC_BUILDER_METHODS
-
-local PublicPageMethods = {}
-
local DISPATCH = {
checkbox = "Checkbox",
slider = "Slider",
@@ -37,15 +25,15 @@ local DISPATCH = {
local COMPOSITE_ROW_DISPATCH = {
border = function(builder, path, spec)
- local result = BuilderMixin.BorderGroup(builder, path, spec)
+ local result = lib.BorderGroup(builder, path, spec)
return result.enabledInit, result.enabledSetting
end,
fontOverride = function(builder, path, spec)
- local result = BuilderMixin.FontOverrideGroup(builder, path, spec)
+ local result = lib.FontOverrideGroup(builder, path, spec)
return result.enabledInit, result.enabledSetting
end,
heightOverride = function(builder, path, spec)
- return BuilderMixin.HeightOverrideSlider(builder, path, spec)
+ return lib.HeightOverrideSlider(builder, path, spec)
end,
}
@@ -88,7 +76,7 @@ local VALID_ROW_TYPES = {
subheader = true,
}
-function BuilderMixin:SetCanvasLayoutDefaults(overrides)
+function lib:SetCanvasLayoutDefaults(overrides)
if not overrides then
return internal.CanvasLayoutDefaults
end
@@ -96,7 +84,7 @@ function BuilderMixin:SetCanvasLayoutDefaults(overrides)
return copyMixin(internal.CanvasLayoutDefaults, overrides)
end
-function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
+function lib:ConfigureCanvasLayout(layout, overrides)
assert(layout, "ConfigureCanvasLayout: layout is required")
if not overrides then
return getCanvasLayoutMetrics(layout)
@@ -107,17 +95,17 @@ function BuilderMixin:ConfigureCanvasLayout(layout, overrides)
end
Deprecated.SetCanvasLayoutDefaults = function(...)
- return BuilderMixin.SetCanvasLayoutDefaults(...)
+ return lib.SetCanvasLayoutDefaults(...)
end
Deprecated.ConfigureCanvasLayout = function(...)
- return BuilderMixin.ConfigureCanvasLayout(...)
+ return lib.ConfigureCanvasLayout(...)
end
-function BuilderMixin:Control(spec)
+function lib:Control(spec)
local methodName = DISPATCH[spec.type]
assert(methodName, "Control: unknown type '" .. tostring(spec.type) .. "'")
- return BuilderMixin[methodName](self, spec)
+ return lib[methodName](self, spec)
end
local function refreshCategory(builder, category)
@@ -292,7 +280,7 @@ local function validatePageDefinition(sourceName, pageDef)
end
local function callBuilder(builder, methodName, ...)
- local method = BuilderMixin[methodName]
+ local method = lib[methodName]
assert(type(method) == "function", "callBuilder: unknown builder method '" .. tostring(methodName) .. "'")
return method(builder, ...)
end
@@ -433,12 +421,23 @@ local function materializePage(page, category)
page._category = category
bindPageLifecycle(page)
+ -- Create the handle before row operations so ctx.page is available in callbacks
+ -- registered during those operations (e.g. onClick, onSet).
+ page._handle = {
+ _category = page._category,
+ GetId = function(_)
+ return page._category:GetID()
+ end,
+ Refresh = function(_)
+ refreshCategory(page._builder, page._category)
+ end,
+ }
+
local created = {}
for _, operation in ipairs(page._operations) do
operation(created)
end
- setmetatable(page, { __index = PublicPageMethods })
page._registered = true
return page
end
@@ -453,22 +452,12 @@ local function appendDeclarativeRows(page, sourceName, rows)
return page
end
-function PublicPageMethods:GetId()
- assert(self._registered and self._category, "page:GetId: page is not registered")
- return self._category:GetID()
-end
-
-function PublicPageMethods:Refresh()
- assert(self._registered and self._category, "page:Refresh: page is not registered")
- refreshCategory(self._builder, self._category)
-end
-
local function createPage(owner, key, rows, opts)
assert(key, "CreatePage: key is required")
opts = opts or {}
local ownerPath = owner.path or ""
- local page = setmetatable({
+ local page = {
_builder = owner._builder or owner,
_root = owner._root or owner,
_section = owner._root and owner or nil,
@@ -485,7 +474,7 @@ local function createPage(owner, key, rows, opts)
name = opts.name,
order = opts.order,
path = opts.path ~= nil and opts.path or ownerPath,
- }, { __index = PublicPageMethods })
+ }
if rows then
appendDeclarativeRows(page, "CreatePage", rows)
@@ -586,24 +575,26 @@ local function createRootPage(root, key, rows, opts)
return page
end
-function BuilderMixin:GetSection(key)
+function lib:GetSection(key)
return self._sections[key]
end
-function BuilderMixin:GetRootPage()
- return self._registeredRootPage
+function lib:GetRootPage()
+ local page = self._registeredRootPage
+ return page and page._handle or nil
end
-function BuilderMixin:GetPage(sectionKey, pageKey)
+function lib:GetPage(sectionKey, pageKey)
if pageKey == nil then
return nil
end
local section = self._sections[sectionKey]
- return section and section._pages[pageKey] or nil
+ local page = section and section._pages[pageKey] or nil
+ return page and page._handle or nil
end
-function BuilderMixin:HasCategory(category)
+function lib:HasCategory(category)
return category ~= nil and self._layouts[category] ~= nil
end
@@ -622,7 +613,7 @@ local function registerPageDefinition(owner, pageDef, defaultName)
})
end
-function BuilderMixin:_registerTree(spec)
+function lib:_registerTree(spec)
assertRootConfigured(self, "Register")
assert(type(spec) == "table", "Register: spec must be a table")
assert(spec.page or spec.sections, "Register: spec requires page or sections")
@@ -652,7 +643,7 @@ function BuilderMixin:_registerTree(spec)
return self
end
-function BuilderMixin:_initializeRoot(name)
+function lib:_initializeRoot(name)
if not self._rootCategory then
assert(name, "_initializeRoot: name is required")
self:_createRootCategory(name)
From 55669b1b09d57852ccdd7d08d183f456dd78e92b Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 12:43:46 +1000
Subject: [PATCH 18/53] phase 4 complete
---
.../CompositeControls/Groups.lua | 40 +++---
.../CompositeControls/Lists.lua | 8 +-
Libs/LibSettingsBuilder/Controls/Base.lua | 66 ++++-----
.../Controls/Collections.lua | 18 +--
Libs/LibSettingsBuilder/Controls/Rows.lua | 42 +++---
Libs/LibSettingsBuilder/Core.lua | 84 ++++++-----
Libs/LibSettingsBuilder/Primitives/Layout.lua | 14 +-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 14 +-
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 13 +-
Libs/LibSettingsBuilder/Utility.lua | 131 +++++++-----------
10 files changed, 204 insertions(+), 226 deletions(-)
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
index 8b2494c6..843eddde 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Groups.lua
@@ -8,11 +8,9 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
-local function callBuilder(builder, methodName, ...)
- return lib[methodName](builder, ...)
-end
+local internal = lib._internal
-function lib:HeightOverrideSlider(sectionPath, spec)
+function lib.HeightOverrideSlider(self, sectionPath, spec)
spec = spec or {}
local childSpec = {
path = sectionPath .. ".height",
@@ -28,8 +26,8 @@ function lib:HeightOverrideSlider(sectionPath, spec)
return value > 0 and value or nil
end,
}
- self:_propagateModifiers(childSpec, spec)
- return callBuilder(self, "Slider", childSpec)
+ internal.propagateModifiers(self, childSpec, spec)
+ return lib.Slider(self, childSpec)
end
--- Font override group.
@@ -38,7 +36,7 @@ end
--- fontFallback function() -> string (fallback font name)
--- fontSizeFallback function() -> number (fallback font size)
--- fontTemplate string (custom template for the font picker)
-function lib:FontOverrideGroup(sectionPath, spec)
+function lib.FontOverrideGroup(self, sectionPath, spec)
spec = spec or {}
local overridePath = sectionPath .. ".overrideFont"
@@ -50,8 +48,8 @@ function lib:FontOverrideGroup(sectionPath, spec)
return value == true
end,
}
- self:_propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = callBuilder(self, "Checkbox", enabledSpec)
+ internal.propagateModifiers(self, enabledSpec, spec)
+ local enabledInit, enabledSetting = lib.Checkbox(self, enabledSpec)
local outerDisabled = spec.disabled
local function isOverrideDisabled()
@@ -77,14 +75,14 @@ function lib:FontOverrideGroup(sectionPath, spec)
return nil
end,
}
- self:_propagateModifiers(fontSpec, spec)
+ internal.propagateModifiers(self, fontSpec, spec)
local fontInit
if spec.fontTemplate then
fontSpec.template = spec.fontTemplate
- fontInit = callBuilder(self, "Custom", fontSpec)
+ fontInit = lib.Custom(self, fontSpec)
else
- fontInit = callBuilder(self, "Dropdown", fontSpec)
+ fontInit = lib.Dropdown(self, fontSpec)
end
local sizeSpec = {
@@ -105,8 +103,8 @@ function lib:FontOverrideGroup(sectionPath, spec)
return 11
end,
}
- self:_propagateModifiers(sizeSpec, spec)
- local sizeInit = callBuilder(self, "Slider", sizeSpec)
+ internal.propagateModifiers(self, sizeSpec, spec)
+ local sizeInit = lib.Slider(self, sizeSpec)
return {
enabledInit = enabledInit,
@@ -116,7 +114,7 @@ function lib:FontOverrideGroup(sectionPath, spec)
}
end
-function lib:BorderGroup(borderPath, spec)
+function lib.BorderGroup(self, borderPath, spec)
spec = spec or {}
local enabledSpec = {
@@ -124,8 +122,8 @@ function lib:BorderGroup(borderPath, spec)
name = spec.enabledName or "Show border",
tooltip = spec.enabledTooltip,
}
- self:_propagateModifiers(enabledSpec, spec)
- local enabledInit, enabledSetting = callBuilder(self, "Checkbox", enabledSpec)
+ internal.propagateModifiers(self, enabledSpec, spec)
+ local enabledInit, enabledSetting = lib.Checkbox(self, enabledSpec)
local thicknessSpec = {
path = borderPath .. ".thickness",
@@ -139,8 +137,8 @@ function lib:BorderGroup(borderPath, spec)
return enabledSetting:GetValue()
end,
}
- self:_propagateModifiers(thicknessSpec, spec)
- local thicknessInit = callBuilder(self, "Slider", thicknessSpec)
+ internal.propagateModifiers(self, thicknessSpec, spec)
+ local thicknessInit = lib.Slider(self, thicknessSpec)
local colorSpec = {
path = borderPath .. ".color",
@@ -151,8 +149,8 @@ function lib:BorderGroup(borderPath, spec)
return enabledSetting:GetValue()
end,
}
- self:_propagateModifiers(colorSpec, spec)
- local colorInit = callBuilder(self, "Color", colorSpec)
+ internal.propagateModifiers(self, colorSpec, spec)
+ local colorInit = lib.Color(self, colorSpec)
return {
enabledInit = enabledInit,
diff --git a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
index 40dd3724..1a1e89c8 100644
--- a/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
+++ b/Libs/LibSettingsBuilder/CompositeControls/Lists.lua
@@ -8,6 +8,8 @@ if not lib or not lib._loadState or not lib._loadState.open then
return
end
+local internal = lib._internal
+
local function buildControlList(builder, basePath, defs, spec, methodName)
local results = {}
spec = spec or {}
@@ -17,17 +19,17 @@ local function buildControlList(builder, basePath, defs, spec, methodName)
name = def.name,
tooltip = def.tooltip,
}
- builder:_propagateModifiers(childSpec, spec)
+ internal.propagateModifiers(builder, childSpec, spec)
local initializer, setting = lib[methodName](builder, childSpec)
results[#results + 1] = { key = def.key, initializer = initializer, setting = setting }
end
return results
end
-function lib:ColorPickerList(basePath, defs, spec)
+function lib.ColorPickerList(self, basePath, defs, spec)
return buildControlList(self, basePath, defs, spec, "Color")
end
-function lib:CheckboxList(basePath, defs, spec)
+function lib.CheckboxList(self, basePath, defs, spec)
return buildControlList(self, basePath, defs, spec, "Checkbox")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index 703580bf..5433caa7 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -15,31 +15,31 @@ local getSettingVariable = internal.getSettingVariable
local applyInputRowEnabledState = internal.applyInputRowEnabledState
local applyInputRowFrame = internal.applyInputRowFrame
local cancelInputPreviewTimer = internal.cancelInputPreviewTimer
-function lib:Checkbox(spec)
- self:_validateSpecFields("checkbox", spec)
- local setting, category = self:_makeProxySetting(spec, Settings.VarType.Boolean, false)
+function lib.Checkbox(self, spec)
+ internal.validateSpecFields(self, "checkbox", spec)
+ local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.Boolean, false)
local initializer = Settings.CreateCheckbox(category, setting, spec.tooltip)
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
-function lib:Slider(spec)
- self:_validateSpecFields("slider", spec)
- local setting, category = self:_makeProxySetting(spec, Settings.VarType.Number, 0)
+function lib.Slider(self, spec)
+ internal.validateSpecFields(self, "slider", spec)
+ local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.Number, 0)
local options = Settings.CreateSliderOptions(spec.min, spec.max, spec.step or 1)
- options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or self._defaultSliderFormatter)
+ options:SetLabelFormatter(MinimalSliderWithSteppersMixin.Label.Right, spec.formatter or internal.defaultSliderFormatter)
local initializer = Settings.CreateSlider(category, setting, options, spec.tooltip)
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
-function lib:Dropdown(spec)
- self:_validateSpecFields("dropdown", spec)
+function lib.Dropdown(self, spec)
+ internal.validateSpecFields(self, "dropdown", spec)
- local binding = self:_resolveBinding(spec)
+ local binding = internal.resolveBinding(self, spec)
local defaultValue = binding.default
if spec.getTransform then
defaultValue = spec.getTransform(defaultValue)
@@ -49,7 +49,7 @@ function lib:Dropdown(spec)
or (type(defaultValue) == "number" and Settings.VarType.Number)
or Settings.VarType.String
- local setting, category = self:_makeProxySetting(spec, varType, "", binding)
+ local setting, category = internal.makeProxySetting(self, spec, varType, "", binding)
local function optionsGenerator()
local container = Settings.CreateControlTextContainer()
local values = type(spec.values) == "function" and spec.values() or spec.values
@@ -80,7 +80,7 @@ function lib:Dropdown(spec)
frame:RefreshDropdownText()
end
end
- self:_registerCategoryRefreshable(category, initializer)
+ internal.registerCategoryRefreshable(self, category, initializer)
end
if initializer.SetSetting and (not initializer.GetSetting or not initializer:GetSetting()) then
@@ -95,7 +95,7 @@ function lib:Dropdown(spec)
frame:SetValue(setting:GetValue())
end
end
- self:_registerCategoryRefreshable(category, initializer)
+ internal.registerCategoryRefreshable(self, category, initializer)
end
if not initializer.GetSetting then
@@ -104,20 +104,20 @@ function lib:Dropdown(spec)
end
end
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
-function lib:Color(spec)
- self:_validateSpecFields("color", spec)
+function lib.Color(self, spec)
+ internal.validateSpecFields(self, "color", spec)
- local variable = self:_makeVarName(spec)
- local category = self:_resolveCategory(spec)
- local binding = self:_resolveBinding(spec)
+ local variable = internal.makeVarName(self, spec)
+ local category = internal.resolveCategory(self, spec)
+ local binding = internal.resolveBinding(self, spec)
local function getter()
- return self:_colorTableToHex(binding.get())
+ return internal.colorTableToHex(self, binding.get())
end
local settingRef
@@ -125,10 +125,10 @@ function lib:Color(spec)
local color = CreateColorFromHexString(hexValue)
local value = { r = color.r, g = color.g, b = color.b, a = color.a }
binding.set(value)
- self:_postSet(spec, value, settingRef)
+ internal.postSet(self, spec, value, settingRef)
end
- local defaultHex = self:_colorTableToHex(binding.default or {})
+ local defaultHex = internal.colorTableToHex(self, binding.default or {})
local setting = Settings.RegisterProxySetting(
category,
variable,
@@ -141,15 +141,15 @@ function lib:Color(spec)
settingRef = setting
local initializer = Settings.CreateColorSwatch(category, setting, spec.tooltip)
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
-function lib:Input(spec)
- self:_validateSpecFields("input", spec)
+function lib.Input(self, spec)
+ internal.validateSpecFields(self, "input", spec)
- local setting, category = self:_makeProxySetting(spec, Settings.VarType.String, "")
+ local setting, category = internal.makeProxySetting(self, spec, Settings.VarType.String, "")
local data = {
debounce = spec.debounce,
maxLetters = spec.maxLetters,
@@ -199,18 +199,18 @@ function lib:Input(spec)
end
Settings.RegisterInitializer(category, initializer)
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
--- Creates a proxy setting backed by a custom frame template.
--- The template's Init receives initializer data containing {setting, name, tooltip}.
-function lib:Custom(spec)
- self:_validateSpecFields("custom", spec)
+function lib.Custom(self, spec)
+ internal.validateSpecFields(self, "custom", spec)
assert(spec.template, "Custom: spec.template is required")
- local setting, category = self:_makeProxySetting(spec, spec.varType or Settings.VarType.String, "")
+ local setting, category = internal.makeProxySetting(self, spec, spec.varType or Settings.VarType.String, "")
local initializer = Settings.CreateElementInitializer(spec.template, {
name = spec.name,
tooltip = spec.tooltip,
@@ -221,7 +221,7 @@ function lib:Custom(spec)
end
Settings.RegisterInitializer(category, initializer)
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, setting
end
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index 4f1df7e2..7f9725cc 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -12,10 +12,10 @@ local internal = lib._internal
local applyCollectionFrame = internal.applyCollectionFrame
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local copyMixin = internal.copyMixin
-function lib:_createCollectionInitializer(spec, errorPrefix)
+function internal.createCollectionInitializer(self, spec, errorPrefix)
assert(spec.height, errorPrefix .. ": spec.height is required")
- local category = self:_resolveCategory(spec)
+ local category = internal.resolveCategory(self, spec)
local data = copyMixin({}, spec)
if data.variant and data.preset == nil then
data.preset = data.variant
@@ -28,7 +28,7 @@ function lib:_createCollectionInitializer(spec, errorPrefix)
controlInitializer._lsbEnabled = enabled
local activeFrame = controlInitializer._lsbActiveFrame
if activeFrame then
- self:_applyCanvasState(activeFrame, enabled)
+ internal.applyCanvasState(self, activeFrame, enabled)
end
end
@@ -38,19 +38,19 @@ function lib:_createCollectionInitializer(spec, errorPrefix)
end
Settings.RegisterInitializer(category, initializer)
- self:_registerCategoryRefreshable(category, initializer)
- self:_applyModifiers(initializer, spec)
+ internal.registerCategoryRefreshable(self, category, initializer)
+ internal.applyModifiers(self, initializer, spec)
return initializer
end
-function lib:List(spec)
+function lib.List(self, spec)
assert(spec.items, "List: spec.items is required")
assert(not spec.sections, "List: spec.sections is not supported")
- return self:_createCollectionInitializer(spec, "List")
+ return internal.createCollectionInitializer(self, spec, "List")
end
-function lib:SectionList(spec)
+function lib.SectionList(self, spec)
assert(spec.sections, "SectionList: spec.sections is required")
- return self:_createCollectionInitializer(spec, "SectionList")
+ return internal.createCollectionInitializer(self, spec, "SectionList")
end
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 6468632b..3b846ae7 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -16,17 +16,17 @@ local applySubheaderFrame = internal.applySubheaderFrame
local copyMixin = internal.copyMixin
local createCustomListRowInitializer = internal.createCustomListRowInitializer
local hideHeaderActionButtons = internal.hideHeaderActionButtons
-function lib:_addLayoutInitializer(spec, initializer, refreshable)
- local category = self:_resolveCategory(spec)
+function internal.addLayoutInitializer(self, spec, initializer, refreshable)
+ local category = internal.resolveCategory(self, spec)
self._layouts[category]:AddInitializer(initializer)
if refreshable then
- self:_registerCategoryRefreshable(category, initializer)
+ internal.registerCategoryRefreshable(self, category, initializer)
end
- self:_applyModifiers(initializer, spec)
+ internal.applyModifiers(self, initializer, spec)
return initializer, category
end
-function lib:Header(textOrSpec, category)
+function lib.Header(self, textOrSpec, category)
local spec = type(textOrSpec) == "table" and textOrSpec or {
name = textOrSpec,
category = category,
@@ -34,13 +34,13 @@ function lib:Header(textOrSpec, category)
assert(not spec.actions, "Header: use PageActions for page header buttons")
local initializer = CreateSettingsListSectionHeaderInitializer(spec.name)
- return self:_addLayoutInitializer(spec, initializer)
+ return internal.addLayoutInitializer(self, spec, initializer)
end
-function lib:PageActions(spec)
+function lib.PageActions(self, spec)
assert(spec.actions, "PageActions: spec.actions is required")
- local category = self:_resolveCategory(spec)
+ local category = internal.resolveCategory(self, spec)
local categoryName = self._subcategoryNames[category]
or (category == self._rootCategory and self._rootCategoryName)
or ""
@@ -55,18 +55,18 @@ function lib:PageActions(spec)
applyHeaderFrame(frame, initializer:GetData())
end
initializer._lsbResetFrame = hideHeaderActionButtons
- return self:_addLayoutInitializer(spec, initializer, true)
+ return internal.addLayoutInitializer(self, spec, initializer, true)
end
-function lib:Subheader(spec)
+function lib.Subheader(self, spec)
local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
_lsbKind = "subheader",
name = spec.name,
}, 28, applySubheaderFrame)
- return self:_addLayoutInitializer(spec, initializer)
+ return internal.addLayoutInitializer(self, spec, initializer)
end
-function lib:InfoRow(spec)
+function lib.InfoRow(self, spec)
local initializer = createCustomListRowInitializer(internal.INFOROW_TEMPLATE, {
_lsbKind = "infoRow",
name = spec.name,
@@ -77,10 +77,10 @@ function lib:InfoRow(spec)
initializer._lsbRefreshFrame = function(frame)
applyInfoRowFrame(frame, initializer:GetData())
end
- return self:_addLayoutInitializer(spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
+ return internal.addLayoutInitializer(self, spec, initializer, type(spec.value) == "function" or type(spec.name) == "function")
end
-function lib:EmbedCanvas(canvas, height, spec)
+function lib.EmbedCanvas(self, canvas, height, spec)
spec = spec or {}
local modifiers = copyMixin({}, spec)
@@ -91,13 +91,13 @@ function lib:EmbedCanvas(canvas, height, spec)
canvas = canvas,
}, height or canvas:GetHeight(), applyEmbedCanvasFrame)
- Settings.RegisterInitializer(self:_resolveCategory(spec), initializer)
- self:_applyModifiers(initializer, modifiers)
+ Settings.RegisterInitializer(internal.resolveCategory(self, spec), initializer)
+ internal.applyModifiers(self, initializer, modifiers)
return initializer
end
-function lib:_ensureConfirmDialog()
+function internal.ensureConfirmDialog(self)
if self._confirmDialogName then
return self._confirmDialogName
end
@@ -122,11 +122,11 @@ function lib:_ensureConfirmDialog()
return self._confirmDialogName
end
-function lib:Button(spec)
- local callbackContext = self:_createCallbackContext(spec)
+function lib.Button(self, spec)
+ local callbackContext = internal.createCallbackContext(self, spec)
local onClick = spec.onClick
if spec.confirm then
- local confirmDialogName = self:_ensureConfirmDialog()
+ local confirmDialogName = internal.ensureConfirmDialog(self)
local confirmText = type(spec.confirm) == "string" and spec.confirm or "Are you sure?"
local originalClick = onClick
onClick = function(ctx)
@@ -141,5 +141,5 @@ function lib:Button(spec)
local initializer = CreateSettingsButtonInitializer(spec.name, spec.buttonText or spec.name, function()
onClick(callbackContext)
end, spec.tooltip, true)
- return self:_addLayoutInitializer(spec, initializer)
+ return internal.addLayoutInitializer(self, spec, initializer)
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 8878192d..8f28ec06 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -694,19 +694,15 @@ local EXTRA_FIELDS_BY_TYPE = {
custom = { template = true, varType = true },
}
-function lib:_makeVarNameFromIdentifier(identifier)
- return self._config.varPrefix .. "_" .. tostring(identifier):gsub("%.", "_")
-end
-
-function lib:_makeVarName(spec)
+function internal.makeVarName(self, spec)
local id = spec.key or spec.path
- return self:_makeVarNameFromIdentifier(id)
+ return self._config.varPrefix .. "_" .. tostring(id):gsub("%.", "_")
end
-function lib:_createCallbackContext(spec, setting)
+function internal.createCallbackContext(self, spec, setting)
return {
builder = self,
- category = self:_resolveCategory(spec),
+ category = internal.resolveCategory(self, spec),
key = spec.key,
page = spec._page and spec._page._handle,
path = spec.path,
@@ -715,11 +711,11 @@ function lib:_createCallbackContext(spec, setting)
}
end
-function lib:_resolveCategory(spec)
+function internal.resolveCategory(self, spec)
return spec.category or self._currentSubcategory or self._rootCategory
end
-function lib:_registerCategoryRefreshable(category, initializer)
+function internal.registerCategoryRefreshable(self, category, initializer)
if not category or not initializer then
return
end
@@ -739,16 +735,16 @@ function lib:_registerCategoryRefreshable(category, initializer)
refreshables[#refreshables + 1] = initializer
end
-function lib:_postSet(spec, value, setting)
- local ctx = self:_createCallbackContext(spec, setting)
+function internal.postSet(self, spec, value, setting)
+ local ctx = internal.createCallbackContext(self, spec, setting)
if spec.onSet then
spec.onSet(ctx, value)
end
self._config.onChanged(ctx, value)
- self:_reevaluateReactiveControls()
+ internal.reevaluateReactiveControls(self)
end
-function lib:_resolveBinding(spec)
+function internal.resolveBinding(self, spec)
local hasPath = spec.path ~= nil
local hasHandler = spec.get ~= nil or spec.set ~= nil
@@ -771,12 +767,12 @@ function lib:_resolveBinding(spec)
return binding
end
-function lib:_makeProxySetting(spec, varType, defaultFallback, binding)
- local variable = self:_makeVarName(spec)
- local category = self:_resolveCategory(spec)
+function internal.makeProxySetting(self, spec, varType, defaultFallback, binding)
+ local variable = internal.makeVarName(self, spec)
+ local category = internal.resolveCategory(self, spec)
local setting
- binding = binding or self:_resolveBinding(spec)
+ binding = binding or internal.resolveBinding(self, spec)
local function getter()
local value = binding.get()
@@ -796,13 +792,13 @@ function lib:_makeProxySetting(spec, varType, defaultFallback, binding)
local function setter(value)
value = applyValue(value)
- self:_postSet(spec, value, setting)
+ internal.postSet(self, spec, value, setting)
end
local function setValueNoCallback(_, value)
value = applyValue(value)
- self._config.onChanged(self:_createCallbackContext(spec, setting), value)
- self:_reevaluateReactiveControls()
+ self._config.onChanged(internal.createCallbackContext(self, spec, setting), value)
+ internal.reevaluateReactiveControls(self)
end
local defaultValue = binding.default
@@ -820,7 +816,7 @@ function lib:_makeProxySetting(spec, varType, defaultFallback, binding)
return setting, category
end
-function lib:_propagateModifiers(target, source)
+function internal.propagateModifiers(self, target, source)
for _, key in ipairs(MODIFIER_KEYS) do
if target[key] == nil then
target[key] = source[key]
@@ -828,7 +824,7 @@ function lib:_propagateModifiers(target, source)
end
end
-function lib:_validateSpecFields(controlType, spec)
+function internal.validateSpecFields(self, controlType, spec)
if not LSB_DEBUG then
return
end
@@ -853,7 +849,7 @@ function lib:_validateSpecFields(controlType, spec)
end
end
-function lib:_setCanvasInteractive(frame, enabled)
+function internal.setCanvasInteractive(self, frame, enabled)
if frame.SetEnabled then
frame:SetEnabled(enabled)
end
@@ -863,12 +859,12 @@ function lib:_setCanvasInteractive(frame, enabled)
if frame.GetChildren then
local children = { frame:GetChildren() }
for i = 1, #children do
- self:_setCanvasInteractive(children[i], enabled)
+ internal.setCanvasInteractive(self, children[i], enabled)
end
end
end
-function lib:_isParentEnabled(spec)
+function internal.isParentEnabled(self, spec)
if not spec._parentInitializer then
return true
end
@@ -887,21 +883,21 @@ function lib:_isParentEnabled(spec)
return setting:GetValue()
end
-function lib:_isControlEnabled(spec)
+function internal.isControlEnabled(self, spec)
if spec.disabled and spec.disabled() then
return false
end
- return self:_isParentEnabled(spec)
+ return internal.isParentEnabled(self, spec)
end
-function lib:_applyCanvasState(canvas, enabled)
+function internal.applyCanvasState(self, canvas, enabled)
if canvas.SetAlpha then
canvas:SetAlpha(enabled and 1 or 0.5)
end
- self:_setCanvasInteractive(canvas, enabled)
+ internal.setCanvasInteractive(self, canvas, enabled)
end
-function lib:_reevaluateReactiveControls()
+function internal.reevaluateReactiveControls(self)
local panel = SettingsPanel
if panel and panel:IsShown() then
local settingsList = panel:GetSettingsList()
@@ -917,37 +913,37 @@ function lib:_reevaluateReactiveControls()
for _, entry in ipairs(self._reactiveControls) do
local spec = entry[2]
if spec.canvas then
- self:_applyCanvasState(spec.canvas, self:_isControlEnabled(spec))
+ internal.applyCanvasState(self, spec.canvas, internal.isControlEnabled(self, spec))
end
end
end
-function lib:_applyEnabledState(initializer, spec)
- local enabled = self:_isControlEnabled(spec)
+function internal.applyEnabledState(self, initializer, spec)
+ local enabled = internal.isControlEnabled(self, spec)
if initializer.SetEnabled then
initializer:SetEnabled(enabled)
end
if spec.canvas then
- self:_applyCanvasState(spec.canvas, enabled)
+ internal.applyCanvasState(self, spec.canvas, enabled)
end
return enabled
end
-function lib:_applyModifiers(initializer, spec)
+function internal.applyModifiers(self, initializer, spec)
if not initializer then
return
end
if spec.disabled or spec.canvas or spec._parentInitializer then
initializer:AddModifyPredicate(function()
- return self:_applyEnabledState(initializer, spec)
+ return internal.applyEnabledState(self, initializer, spec)
end)
- self:_applyEnabledState(initializer, spec)
+ internal.applyEnabledState(self, initializer, spec)
end
if spec._parentInitializer then
initializer:SetParentInitializer(spec._parentInitializer, function()
- return self:_isParentEnabled(spec)
+ return internal.isParentEnabled(self, spec)
end)
end
@@ -962,7 +958,7 @@ function lib:_applyModifiers(initializer, spec)
end
end
-function lib:_colorTableToHex(tbl)
+function internal.colorTableToHex(self, tbl)
if not tbl then
return "FFFFFFFF"
end
@@ -975,14 +971,14 @@ function lib:_colorTableToHex(tbl)
)
end
-function lib:_storeCategory(name, category, layout)
+function internal.storeCategory(self, name, category, layout)
self._subcategories[name] = category
self._subcategoryNames[category] = name
self._layouts[category] = layout
return category
end
-lib._defaultSliderFormatter = defaultSliderFormatter
+internal.defaultSliderFormatter = defaultSliderFormatter
--- Create a new LibSettingsBuilder runtime instance.
---@param config table
@@ -1039,7 +1035,7 @@ function lib.New(selfOrConfig, maybeConfig)
_nextRootPageSequence = 0,
_nextSectionSequence = 0,
name = nil,
- }, { __index = lib })
+ }, { __index = lib._publicApi or lib })
if config.name ~= nil then
lsb:_initializeRoot(config.name)
@@ -1055,6 +1051,7 @@ function lib.New(selfOrConfig, maybeConfig)
return lsb
end
+-- Export local functions to internal for cross-file access
internal.installPageLifecycleHooks = installPageLifecycleHooks
internal.copyMixin = copyMixin
internal.setInitializerExtent = setInitializerExtent
@@ -1071,6 +1068,7 @@ internal.evaluateStaticOrFunction = evaluateStaticOrFunction
internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
+-- Clear stale keys from prior MINOR versions (LibStub reuses the same table)
lib.CanvasLayout = nil
lib.CanvasLayoutDefaults = nil
lib.CreateColorSwatch = nil
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index 14bf637d..4b5d23e1 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -12,7 +12,7 @@ local internal = lib._internal
local copyMixin = internal.copyMixin
local Deprecated = lib.LSBDeprecated
-function lib:_createRootCategory(name)
+function internal.createRootCategory(self, name)
local category, layout = Settings.RegisterVerticalLayoutCategory(name)
self._rootCategory = category
self._rootCategoryName = name
@@ -21,17 +21,17 @@ function lib:_createRootCategory(name)
return category
end
-function lib:_createSubcategory(name, parentCategory)
+function internal.createSubcategory(self, name, parentCategory)
local parent = parentCategory or self._rootCategory
local subcategory, layout = Settings.RegisterVerticalLayoutSubcategory(parent, name)
- self._currentSubcategory = self:_storeCategory(name, subcategory, layout)
+ self._currentSubcategory = internal.storeCategory(self, name, subcategory, layout)
return subcategory
end
-function lib:_createCanvasSubcategory(frame, name, parentCategory)
+function internal.createCanvasSubcategory(self, frame, name, parentCategory)
local parent = parentCategory or self._rootCategory
local subcategory, layout = Settings.RegisterCanvasLayoutSubcategory(parent, frame, name)
- return self:_storeCategory(name, subcategory, layout)
+ return internal.storeCategory(self, name, subcategory, layout)
end
--- Creates a canvas subcategory with a CanvasLayout engine attached.
@@ -41,9 +41,9 @@ end
---@param name string Subcategory display name.
---@param parentCategory? table Parent category (defaults to root).
---@return table layout CanvasLayout instance (layout.frame for the raw frame).
-function lib:CreateCanvasLayout(name, parentCategory)
+function lib.CreateCanvasLayout(self, name, parentCategory)
local frame = CreateFrame("Frame", nil)
- self:_createCanvasSubcategory(frame, name, parentCategory)
+ internal.createCanvasSubcategory(self, frame, name, parentCategory)
local metrics = copyMixin({}, internal.CanvasLayoutDefaults)
return setmetatable({
frame = frame,
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index f68e3026..b34a4f89 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -236,7 +236,7 @@ describe("LibSettingsBuilder Builder", function()
assert.is_nil(page.List)
end)
- it("returns an lsb instance with all methods accessible via lib prototype", function()
+ it("returns an lsb instance with only the public API on its prototype", function()
local sb = createBuilder({
sections = {
{
@@ -252,16 +252,18 @@ describe("LibSettingsBuilder Builder", function()
},
})
- -- Public API methods accessible via __index = lib
+ -- Public API accessible via narrow prototype
assert.is_function(sb.GetSection)
assert.is_function(sb.GetRootPage)
assert.is_function(sb.GetPage)
assert.is_function(sb.HasCategory)
- -- Control builder methods also accessible via prototype
- assert.is_function(sb.Checkbox)
- assert.is_function(sb.Slider)
- assert.is_function(sb.BorderGroup)
+ -- Internal row-builder methods not on the public prototype
+ assert.is_nil(sb.Checkbox)
+ assert.is_nil(sb.Slider)
+ assert.is_nil(sb.BorderGroup)
+ assert.is_nil(sb.Control)
+ assert.is_nil(sb.EmbedCanvas)
-- Instance state is raw on the table
assert.is_table(rawget(sb, "_sections"))
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index 997a387e..56192f2b 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -54,7 +54,7 @@ describe("LibSettingsBuilder Core", function()
assert.is_table(deprecated.CanvasLayout)
end)
- it("exposes the full builder API on instances via lib prototype", function()
+ it("exposes only the public API on builder instances", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local sb = lsb.New({
name = "Phase 2",
@@ -79,10 +79,13 @@ describe("LibSettingsBuilder Core", function()
assert.is_function(sb.GetRootPage)
assert.is_function(sb.GetPage)
assert.is_function(sb.HasCategory)
- assert.is_function(sb.Checkbox)
- assert.is_function(sb.List)
- assert.is_function(sb.Control)
- assert.is_function(sb.EmbedCanvas)
+
+ -- Internal builder methods not on the public prototype
+ assert.is_nil(sb.Control)
+ assert.is_nil(sb.Checkbox)
+ assert.is_nil(sb.List)
+ assert.is_nil(sb.EmbedCanvas)
+ assert.is_nil(sb.BorderGroup)
end)
it("store/defaults bindings resolve nested values and defaults", function()
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 7acfbd09..31a5f2a8 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -14,29 +14,6 @@ local installPageLifecycleHooks = internal.installPageLifecycleHooks
local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
local Deprecated = lib.LSBDeprecated
-local DISPATCH = {
- checkbox = "Checkbox",
- slider = "Slider",
- dropdown = "Dropdown",
- color = "Color",
- input = "Input",
- custom = "Custom",
-}
-
-local COMPOSITE_ROW_DISPATCH = {
- border = function(builder, path, spec)
- local result = lib.BorderGroup(builder, path, spec)
- return result.enabledInit, result.enabledSetting
- end,
- fontOverride = function(builder, path, spec)
- local result = lib.FontOverrideGroup(builder, path, spec)
- return result.enabledInit, result.enabledSetting
- end,
- heightOverride = function(builder, path, spec)
- return lib.HeightOverrideSlider(builder, path, spec)
- end,
-}
-
local PROXY_ROW_TYPES = {
checkbox = true,
slider = true,
@@ -76,37 +53,24 @@ local VALID_ROW_TYPES = {
subheader = true,
}
-function lib:SetCanvasLayoutDefaults(overrides)
+local function setCanvasLayoutDefaults(overrides)
if not overrides then
return internal.CanvasLayoutDefaults
end
-
return copyMixin(internal.CanvasLayoutDefaults, overrides)
end
-function lib:ConfigureCanvasLayout(layout, overrides)
+local function configureCanvasLayout(layout, overrides)
assert(layout, "ConfigureCanvasLayout: layout is required")
if not overrides then
return getCanvasLayoutMetrics(layout)
end
-
layout._metrics = copyMixin(copyMixin({}, internal.CanvasLayoutDefaults), overrides)
return layout._metrics
end
-Deprecated.SetCanvasLayoutDefaults = function(...)
- return lib.SetCanvasLayoutDefaults(...)
-end
-
-Deprecated.ConfigureCanvasLayout = function(...)
- return lib.ConfigureCanvasLayout(...)
-end
-
-function lib:Control(spec)
- local methodName = DISPATCH[spec.type]
- assert(methodName, "Control: unknown type '" .. tostring(spec.type) .. "'")
- return lib[methodName](self, spec)
-end
+Deprecated.SetCanvasLayoutDefaults = setCanvasLayoutDefaults
+Deprecated.ConfigureCanvasLayout = configureCanvasLayout
local function refreshCategory(builder, category)
if not category then
@@ -279,16 +243,10 @@ local function validatePageDefinition(sourceName, pageDef)
assert(type(pageDef.rows) == "table", sourceName .. ": page definition requires rows")
end
-local function callBuilder(builder, methodName, ...)
- local method = lib[methodName]
- assert(type(method) == "function", "callBuilder: unknown builder method '" .. tostring(methodName) .. "'")
- return method(builder, ...)
-end
-
-local function registerLabeledList(page, spec, methodName)
+local function registerLabeledList(page, spec, builderMethod)
local builder = page._builder
if spec.label then
- local labelInit = callBuilder(builder, "Subheader", {
+ local labelInit = lib.Subheader(builder, {
name = spec.label,
disabled = spec.disabled,
hidden = spec.hidden,
@@ -297,13 +255,7 @@ local function registerLabeledList(page, spec, methodName)
spec._parentInitializer = spec._parentInitializer or labelInit
end
- local results = callBuilder(
- builder,
- methodName,
- resolvePagePath(page.path or "", spec.path),
- spec.defs or {},
- spec
- )
+ local results = builderMethod(builder, resolvePagePath(page.path or "", spec.path), spec.defs or {}, spec)
return results[1] and results[1].initializer, results[1] and results[1].setting
end
@@ -325,43 +277,57 @@ local function registerDeclarativeRow(sourceName, page, row, created)
spec._page = page
local initializer, setting
+ local path = resolvePagePath(page.path or "", spec.path)
if rowType == "button" then
- initializer = callBuilder(builder, "Button", spec)
+ initializer = lib.Button(builder, spec)
elseif rowType == "canvas" then
- initializer = callBuilder(builder, "EmbedCanvas", spec.canvas, spec.height, spec)
+ initializer = lib.EmbedCanvas(builder, spec.canvas, spec.height, spec)
elseif rowType == "checkboxList" then
- initializer, setting = registerLabeledList(page, spec, "CheckboxList")
+ initializer, setting = registerLabeledList(page, spec, lib.CheckboxList)
elseif rowType == "colorList" then
- initializer, setting = registerLabeledList(page, spec, "ColorPickerList")
+ initializer, setting = registerLabeledList(page, spec, lib.ColorPickerList)
elseif rowType == "header" then
- initializer = callBuilder(builder, "Header", spec)
+ initializer = lib.Header(builder, spec)
elseif rowType == "info" then
- initializer = callBuilder(builder, "InfoRow", spec)
+ initializer = lib.InfoRow(builder, spec)
elseif rowType == "list" then
- initializer = callBuilder(builder, "List", spec)
+ initializer = lib.List(builder, spec)
elseif rowType == "pageActions" then
- initializer = callBuilder(builder, "PageActions", spec)
+ initializer = lib.PageActions(builder, spec)
elseif rowType == "sectionList" then
- initializer = callBuilder(builder, "SectionList", spec)
+ initializer = lib.SectionList(builder, spec)
elseif rowType == "subheader" then
- initializer = callBuilder(builder, "Subheader", spec)
- elseif COMPOSITE_ROW_DISPATCH[rowType] then
- initializer, setting = COMPOSITE_ROW_DISPATCH[rowType](
- builder,
- resolvePagePath(page.path or "", spec.path),
- spec
- )
+ initializer = lib.Subheader(builder, spec)
+ elseif rowType == "border" then
+ local result = lib.BorderGroup(builder, path, spec)
+ initializer, setting = result.enabledInit, result.enabledSetting
+ elseif rowType == "fontOverride" then
+ local result = lib.FontOverrideGroup(builder, path, spec)
+ initializer, setting = result.enabledInit, result.enabledSetting
+ elseif rowType == "heightOverride" then
+ initializer, setting = lib.HeightOverrideSlider(builder, path, spec)
elseif PROXY_ROW_TYPES[rowType] then
if not spec.get then
- spec.path = resolvePagePath(page.path or "", spec.path)
+ spec.path = path
elseif not spec.key then
spec.key = row.id
end
if spec.get and not spec.key then
error(sourceName .. ": handler-mode row '" .. tostring(row.id or spec.name) .. "' requires key or id")
end
- spec.type = rowType
- initializer, setting = callBuilder(builder, "Control", spec)
+ if rowType == "checkbox" then
+ initializer, setting = lib.Checkbox(builder, spec)
+ elseif rowType == "slider" then
+ initializer, setting = lib.Slider(builder, spec)
+ elseif rowType == "dropdown" then
+ initializer, setting = lib.Dropdown(builder, spec)
+ elseif rowType == "color" then
+ initializer, setting = lib.Color(builder, spec)
+ elseif rowType == "input" then
+ initializer, setting = lib.Input(builder, spec)
+ elseif rowType == "custom" then
+ initializer, setting = lib.Custom(builder, spec)
+ end
else
error(sourceName .. ": unknown row type '" .. tostring(rowType) .. "'")
end
@@ -373,7 +339,7 @@ end
local function createManagedSubcategory(builder, name, parentCategory)
local previous = builder._currentSubcategory
- local category = builder:_createSubcategory(name, parentCategory)
+ local category = internal.createSubcategory(builder, name, parentCategory)
builder._currentSubcategory = previous
return category
end
@@ -613,7 +579,7 @@ local function registerPageDefinition(owner, pageDef, defaultName)
})
end
-function lib:_registerTree(spec)
+function internal.registerTree(self, spec)
assertRootConfigured(self, "Register")
assert(type(spec) == "table", "Register: spec must be a table")
assert(spec.page or spec.sections, "Register: spec requires page or sections")
@@ -643,10 +609,10 @@ function lib:_registerTree(spec)
return self
end
-function lib:_initializeRoot(name)
+function internal.initializeRoot(self, name)
if not self._rootCategory then
assert(name, "_initializeRoot: name is required")
- self:_createRootCategory(name)
+ internal.createRootCategory(self, name)
elseif name and self._rootCategoryName ~= name then
error("_initializeRoot: root already exists with name '" .. tostring(self._rootCategoryName) .. "'")
end
@@ -661,4 +627,13 @@ function lib:_initializeRoot(name)
return self
end
+lib._publicApi = {
+ GetSection = lib.GetSection,
+ GetRootPage = lib.GetRootPage,
+ GetPage = lib.GetPage,
+ HasCategory = lib.HasCategory,
+ _registerTree = internal.registerTree,
+ _initializeRoot = internal.initializeRoot,
+}
+
lib._loadState.open = nil
From 271338c4165b403ce224a85966aa110a118257c0 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 14:22:46 +1000
Subject: [PATCH 19/53] phase 5 complete
---
.github/agents/LuaReview.agent.md | 118 ++++++++++++++++++
.../Controls/CollectionFrames.lua | 2 +-
Libs/LibSettingsBuilder/Core.lua | 14 +--
Libs/LibSettingsBuilder/Primitives/Layout.lua | 7 +-
Libs/LibSettingsBuilder/Primitives/Rows.lua | 4 +-
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 18 +--
Libs/LibSettingsBuilder/Utility.lua | 21 ----
7 files changed, 130 insertions(+), 54 deletions(-)
create mode 100644 .github/agents/LuaReview.agent.md
diff --git a/.github/agents/LuaReview.agent.md b/.github/agents/LuaReview.agent.md
new file mode 100644
index 00000000..3fd81de1
--- /dev/null
+++ b/.github/agents/LuaReview.agent.md
@@ -0,0 +1,118 @@
+---
+name: LuaReview
+description: Reviews World of Warcraft addon Lua code for correctness, leanness, and architectural health. Use when auditing a diff, a file, or a module before merge, or when asked to "review", "audit", or "critique" Lua changes. Read-only by default — produces findings, not edits.
+argument-hint: A file path, diff, PR number, or description of the code to review. Optionally specify scope (e.g., "only recent changes", "full file", "module boundaries").
+model: Claude Opus 4.7 (copilot)
+tools: [vscode/memory, vscode/resolveMemoryFileUri, read, agent, search, oraios/serena/activate_project, oraios/serena/check_onboarding_performed, oraios/serena/edit_memory, oraios/serena/find_referencing_symbols, oraios/serena/find_symbol, oraios/serena/get_current_config, oraios/serena/get_symbols_overview, oraios/serena/initial_instructions, oraios/serena/list_memories, oraios/serena/onboarding, oraios/serena/read_memory, oraios/serena/rename_memory, oraios/serena/write_memory, todo]
+---
+
+You are a senior WoW addon code reviewer. You read code carefully and report honestly. You do not rewrite code unless explicitly asked — your job is to find problems and explain them with enough context that the author can fix them.
+
+## Operating principles
+
+- Be direct. No hedging, no sycophancy, no "overall this looks great" filler. If the code is fine, say so in one sentence and stop.
+- Report findings in priority order: correctness bugs > taint/security > architecture > duplication > style.
+- Every finding must cite a specific file and line range. No vague "consider refactoring X" without pointing at the offending code.
+- Stress-test your own claims before shipping them. If you catch yourself inflating an issue's severity, downgrade it. If a "problem" runs once at load and saves microseconds, say it's cosmetic.
+- Do not invent issues to pad the review. A short review of real problems beats a long review of fabricated ones.
+- Do not propose performance changes without confirming how often the code actually runs. Event handlers that fire on `PLAYER_ENTERING_WORLD` are not hot paths.
+
+## What to look for
+
+### Correctness and runtime safety
+
+- WoW Lua is 5.1. Flag `goto`, labels, integer division `//`, bitwise operators, and other post-5.1 syntax.
+- Secret values: `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, `C_UnitAuras.GetUnitAuraBySpellID`. They may only be nil-checked or passed to APIs that accept secrets. Flag arithmetic, comparisons, boolean tests, indexing, iteration, or use as table keys.
+- Taint hazards: hooks on Blizzard secure/UI functions (`Settings.CreateElementInitializer`, edit boxes, action bars), global hooks intended to simulate XML templates in Lua, modifications to shared tables.
+- Deprecated Blizzard APIs (e.g. `GetSpecialization`, `GetItemInfo`, `GetTalentInfo`, deprecated chat/spell/item helpers). Flag and point at the `C_*` replacement.
+- Event handlers that can throw and wedge later work without `pcall` protection around critical state flags.
+- Forward declarations (reorder instead).
+- File-level mutable state that should live on an instance as `self._field`.
+- Nil-checking or wrapping built-ins like `issecretvalue`, `issecrettable`, `canaccesstable` — don't.
+
+### Architecture and coupling
+
+- Tight coupling across module boundaries where an event, callback, or message would do.
+- Multiple sources of truth for the same derived state. Derived values should be computed once and read everywhere.
+- Production code with fallback paths, compatibility shims, or defensive adapters that no supported runtime actually needs. Call out each branch that can never execute.
+- Library code reaching into addon internals, or addon code reaching into library internals that aren't part of the public API.
+- Trivial passthrough wrappers (`local function foo(x) return bar(x) end`) that add no value.
+- Abstractions introduced for a single caller. Helpers should have 2+ callers or a distinct, independently testable contract.
+- Indirection around fixed literal values or stable API signatures.
+- Mapping functions for small fixed domains that should be constant lookup tables.
+
+### Duplication and dead migration patterns
+
+- **Critical: flag cases where a function was renamed or replaced but the old symbol was kept as a thin wrapper pointing at the new one, instead of updating all call sites.** This is one of the worst smells — it doubles the surface area, confuses readers, and usually signals an incomplete refactor. Name the old symbol, the new symbol, and every call site still using the old one.
+- Repeated table literals with identical structure that should be built by a constructor.
+- Two- or three-call sequences repeated across many callbacks that should be one wrapper.
+- Linear scans over fixed load-time sets where an `O(1)` lookup table would be clearer and faster.
+- Closures that differ only by one parameter (e.g. `direction = -1` vs `+1`) and should share a parameterised path.
+- Dead code, stale fields, impossible branches, unused locale strings, unused upvalues.
+- Fields assigned to `nil` that are never read again.
+
+### Leanness
+
+- The best code is lean, efficient, and small. When two solutions exist and one is half the size of the other, the smaller one wins unless the larger one has a concrete justification (clarity for a non-obvious invariant, measurable performance, independent testability).
+- Inline single-use local functions into their sole call site. A three-line helper with one caller is noise.
+- Prefer compact single-line bodies for trivial functions.
+- Flag unnecessary intermediate local assignments that don't improve clarity or performance.
+- Flag over-engineered factories, builders, or dispatch tables where a direct call would be shorter and clearer.
+
+### Performance (only when it matters)
+
+- Determine call frequency before proposing a perf change. Load-time and UI-click paths are not hot paths.
+- Never `OnUpdate` or frame-rate tickers. Event-driven plus a single deferred timer.
+- Reuse tables on hot paths with `wipe()`.
+- Superseded timers must be cancelled before scheduling new ones.
+- Debug logging on hot paths must be guarded by the debug-enabled check.
+- Callback iteration should be zero-allocation and tolerant of removal, not snapshot-copied.
+- Periodic setup work must stop once targets are handled.
+- Avoid stacked `C_Timer.After(0)` chains — defer once.
+
+### Tests
+
+- Tests must exercise real production code, not mirrored reimplementations.
+- Stubs should match the canonical Blizzard function signature, not a wrapper.
+- Test file paths should mirror source paths; test load order mirrors TOC load order.
+- Be skeptical of test changes that make failures go away — the failure may be a real bug.
+- Coverage gains that don't meaningfully validate production code are not gains.
+- Library tests stay under `Libs//Tests/` and must not depend on addon internals.
+
+### Style and hygiene
+
+- Copyright header on every Lua file.
+- Private fields and methods prefixed with `_`.
+- Shared modules aliased once at file scope when reused.
+- No emojis in code or comments.
+
+## Output format
+
+Structure the review like this:
+
+```
+## Summary
+One or two sentences. State whether the code is ship-ready, needs changes, or has fundamental issues.
+
+## Blocking issues
+Correctness bugs, taint hazards, deprecated API use, broken tests. Each with file:line and a concrete fix direction.
+
+## Architectural concerns
+Coupling, duplication, dead migration wrappers, over-engineering. Each with file:line and what to do instead.
+
+## Leanness opportunities
+Places where the code could be materially smaller or simpler. Skip cosmetic wins under ~3 lines saved.
+
+## Nits
+Style, naming, minor cleanup. One line each.
+```
+
+If a section has no findings, omit it. Do not write "No issues found" under every heading.
+
+## What not to do
+
+- Do not rewrite the code. Point at problems and describe the fix; let the author implement.
+- Do not suggest speculative features or "while you're in there" refactors unrelated to the submitted change.
+- Do not recommend adding comments or docstrings to code that wasn't part of the change.
+- Do not grade on a curve. A small diff with one real bug is not "looks good overall, minor note".
+- Do not pad with generic advice ("consider adding tests", "think about error handling") unless you can point at the specific missing case.
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index 778ffef7..aff17c7b 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -698,7 +698,7 @@ local function ensureSectionHeaderRow(content, headers, sectionKey, title)
row = CreateFrame("Frame", nil, content)
row:SetHeight(28)
- row._title = lib.CreateSubheaderTitle(row, title)
+ row._title = internal.createSubheaderTitle(row, title)
headers[sectionKey] = row
return row
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 8f28ec06..0be98c36 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -14,10 +14,8 @@ end
lib._loadState = { open = true }
lib._internal = lib._internal or {}
-lib.LSBDeprecated = lib.LSBDeprecated or {}
local internal = lib._internal
-local Deprecated = lib.LSBDeprecated
internal.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
internal.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
@@ -366,9 +364,6 @@ local function createHeaderTitle(parent, text)
return createTitle(parent, "GameFontHighlightLarge", 7, -16, text)
end
-Deprecated.CreateHeaderTitle = createHeaderTitle
-Deprecated.CreateSubheaderTitle = createSubheaderTitle
-
--------------------------------------------------------------------------------
-- CanvasLayout: Vertical stacking engine for canvas subcategory pages.
-- Replicates Blizzard's Settings panel positioning so canvas pages are
@@ -396,11 +391,8 @@ internal.CanvasLayoutDefaults = internal.CanvasLayoutDefaults
swatchCenterX = DEFAULT_SWATCH_CENTER_X,
verifiedPatch = "Retail 12.0/12.1",
}
-Deprecated.CanvasLayoutDefaults = internal.CanvasLayoutDefaults
-
local CanvasLayout = {}
internal.CanvasLayout = CanvasLayout
-Deprecated.CanvasLayout = CanvasLayout
local function getCanvasLayoutMetrics(layout)
return layout._metrics or internal.CanvasLayoutDefaults
@@ -553,8 +545,6 @@ local function createColorSwatch(parent)
end
internal.createColorSwatch = createColorSwatch
-Deprecated.CreateColorSwatch = createColorSwatch
-
--------------------------------------------------------------------------------
-- Path accessors: built-in dot-path resolution with numeric key support
--------------------------------------------------------------------------------
@@ -1067,10 +1057,13 @@ internal.applyActionButtonTextures = applyActionButtonTextures
internal.evaluateStaticOrFunction = evaluateStaticOrFunction
internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
+internal.createHeaderTitle = createHeaderTitle
+internal.createSubheaderTitle = createSubheaderTitle
-- Clear stale keys from prior MINOR versions (LibStub reuses the same table)
lib.CanvasLayout = nil
lib.CanvasLayoutDefaults = nil
+lib.CreateCanvasLayout = nil
lib.CreateColorSwatch = nil
lib.CreateHeaderTitle = nil
lib.CreateSubheaderTitle = nil
@@ -1079,3 +1072,4 @@ lib.INFOROW_TEMPLATE = nil
lib.INPUTROW_TEMPLATE = nil
lib.SCROLL_DROPDOWN_TEMPLATE = nil
lib.SUBHEADER_TEMPLATE = nil
+lib.LSBDeprecated = nil
diff --git a/Libs/LibSettingsBuilder/Primitives/Layout.lua b/Libs/LibSettingsBuilder/Primitives/Layout.lua
index 4b5d23e1..25b10e08 100644
--- a/Libs/LibSettingsBuilder/Primitives/Layout.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Layout.lua
@@ -10,7 +10,6 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
-local Deprecated = lib.LSBDeprecated
function internal.createRootCategory(self, name)
local category, layout = Settings.RegisterVerticalLayoutCategory(name)
@@ -41,7 +40,7 @@ end
---@param name string Subcategory display name.
---@param parentCategory? table Parent category (defaults to root).
---@return table layout CanvasLayout instance (layout.frame for the raw frame).
-function lib.CreateCanvasLayout(self, name, parentCategory)
+function internal.createCanvasLayout(self, name, parentCategory)
local frame = CreateFrame("Frame", nil)
internal.createCanvasSubcategory(self, frame, name, parentCategory)
local metrics = copyMixin({}, internal.CanvasLayoutDefaults)
@@ -52,7 +51,3 @@ function lib.CreateCanvasLayout(self, name, parentCategory)
_metrics = metrics,
}, { __index = internal.CanvasLayout })
end
-
-Deprecated.CreateCanvasLayout = function(...)
- return lib.CreateCanvasLayout(...)
-end
diff --git a/Libs/LibSettingsBuilder/Primitives/Rows.lua b/Libs/LibSettingsBuilder/Primitives/Rows.lua
index 5a39b241..1590539d 100644
--- a/Libs/LibSettingsBuilder/Primitives/Rows.lua
+++ b/Libs/LibSettingsBuilder/Primitives/Rows.lua
@@ -57,7 +57,7 @@ local function ensureSubheaderTitle(frame)
return frame._lsbSubheaderTitle
end
- local title = lib.CreateSubheaderTitle(frame)
+ local title = internal.createSubheaderTitle(frame)
frame._lsbSubheaderTitle = title
frame.Title = title
return title
@@ -90,7 +90,7 @@ local function ensureHeaderRowWidgets(frame)
return frame
end
- frame._lsbHeaderTitle = lib.CreateHeaderTitle(frame)
+ frame._lsbHeaderTitle = internal.createHeaderTitle(frame)
frame._lsbHeaderActionButtons = frame._lsbHeaderActionButtons or {}
return frame
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index 56192f2b..5a50d284 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -33,27 +33,17 @@ describe("LibSettingsBuilder Core", function()
it("loads the split library through the shared ordered loader", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
assert.is_table(lsb)
- assert.is_table(lsb.LSBDeprecated)
+ assert.is_nil(lsb.LSBDeprecated)
assert.is_nil(lsb.BuilderMixin)
assert.is_nil(lsb.CanvasLayout)
assert.is_nil(lsb.CanvasLayoutDefaults)
+ assert.is_nil(lsb.CreateCanvasLayout)
assert.is_nil(lsb.CreateColorSwatch)
+ assert.is_nil(lsb.CreateHeaderTitle)
+ assert.is_nil(lsb.CreateSubheaderTitle)
assert.is_nil(lsb._loadState.open)
end)
- it("exposes the planned deprecated namespace aliases", function()
- local lsb = LibStub("LibSettingsBuilder-1.0")
- local deprecated = lsb.LSBDeprecated
-
- assert.is_function(deprecated.CreateColorSwatch)
- assert.is_function(deprecated.CreateHeaderTitle)
- assert.is_function(deprecated.CreateSubheaderTitle)
- assert.is_function(deprecated.CreateCanvasLayout)
- assert.is_function(deprecated.SetCanvasLayoutDefaults)
- assert.is_function(deprecated.ConfigureCanvasLayout)
- assert.is_table(deprecated.CanvasLayout)
- end)
-
it("exposes only the public API on builder instances", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local sb = lsb.New({
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 31a5f2a8..544c0c2e 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -11,8 +11,6 @@ end
local internal = lib._internal
local copyMixin = internal.copyMixin
local installPageLifecycleHooks = internal.installPageLifecycleHooks
-local getCanvasLayoutMetrics = internal.getCanvasLayoutMetrics
-local Deprecated = lib.LSBDeprecated
local PROXY_ROW_TYPES = {
checkbox = true,
@@ -53,25 +51,6 @@ local VALID_ROW_TYPES = {
subheader = true,
}
-local function setCanvasLayoutDefaults(overrides)
- if not overrides then
- return internal.CanvasLayoutDefaults
- end
- return copyMixin(internal.CanvasLayoutDefaults, overrides)
-end
-
-local function configureCanvasLayout(layout, overrides)
- assert(layout, "ConfigureCanvasLayout: layout is required")
- if not overrides then
- return getCanvasLayoutMetrics(layout)
- end
- layout._metrics = copyMixin(copyMixin({}, internal.CanvasLayoutDefaults), overrides)
- return layout._metrics
-end
-
-Deprecated.SetCanvasLayoutDefaults = setCanvasLayoutDefaults
-Deprecated.ConfigureCanvasLayout = configureCanvasLayout
-
local function refreshCategory(builder, category)
if not category then
return
From 207f56691ca1fbb100a895b78310ace9357734aa Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:09:43 +1000
Subject: [PATCH 20/53] Add agent prompts.
---
.github/agents/Developer.agent.md | 54 +++++++++++++++
.github/agents/Iterate.agent.md | 109 ++++++++++++++++++++++++++++++
.github/agents/LuaReview.agent.md | 2 +-
3 files changed, 164 insertions(+), 1 deletion(-)
create mode 100644 .github/agents/Developer.agent.md
create mode 100644 .github/agents/Iterate.agent.md
diff --git a/.github/agents/Developer.agent.md b/.github/agents/Developer.agent.md
new file mode 100644
index 00000000..7715aa0b
--- /dev/null
+++ b/.github/agents/Developer.agent.md
@@ -0,0 +1,54 @@
+---
+name: Developer
+description: Implements coding tasks end-to-end in the EnhancedCooldownManager workspace. Writes and edits Lua, runs validation (busted, luacheck), and reports a concise summary of changes. Use for any request that involves modifying source, tests, or tooling.
+argument-hint: A concrete coding task (e.g. "add X to module Y", "fix bug in Z", "refactor W").
+model: GPT-5.4 (copilot)
+tools: [vscode/resolveMemoryFileUri, vscode/askQuestions, execute, read, agent, edit, search, web, browser, 'context7/*', 'oraios/serena/*', todo]
+---
+
+You are a senior WoW addon engineer working in the EnhancedCooldownManager repository. You implement the task given to you directly — no orchestration, no delegation.
+
+## Responsibilities
+
+1. Understand the task. If it is ambiguous in a way that materially changes the implementation, make the most defensible assumption and state it in your summary. Do not stall asking questions.
+2. Gather only the context you need (symbolic search, targeted reads). Do not read entire files or the whole workspace unless necessary.
+3. Implement the change. Follow the repository's `AGENTS.md` rules strictly:
+ - WoW Lua 5.1 target; no `goto`, `//`, etc.
+ - Standard copyright header on all Lua files.
+ - Keep [ARCHITECTURE.md](../../ARCHITECTURE.md) accurate when architecture-level changes land.
+ - Prefer event-driven, loosely coupled designs; reuse existing helpers; no gratuitous abstraction.
+ - Respect secret-value rules for `UnitPower*` and `C_UnitAuras.GetUnitAuraBySpellID`.
+4. Run validation and confirm green before reporting done:
+ - `busted Tests`
+ - Relevant library suites (`busted --run libsettingsbuilder`, `libconsole`, `libevent`, `liblsmsettingswidgets`) when touching those libraries.
+ - `luacheck . -q`
+5. Fix any failures you introduced. Do not paper over pre-existing failures; call them out instead.
+
+## Output
+
+Return a concise summary containing:
+
+- **Files changed** — bulleted list with one-line purpose each.
+- **Key decisions** — any non-obvious choice or assumption.
+- **Validation** — exact commands run and their pass/fail state.
+- **Known gaps** — anything you deliberately did not do and why.
+
+Keep it terse. No cheerleading, no restating the task.
+
+## Responding to review findings
+
+When the prompt includes a `REVIEW FINDINGS TO ADDRESS` section, treat each finding as the default-correct position. The reviewer has more context on cross-cutting concerns and has already stress-tested the finding before filing it, so the burden of proof is on you to justify *not* fixing it — and that burden is high.
+
+- Default to **FIXED**. If the fix is small, safe, and within scope, just do it. Do not relitigate taste, naming, leanness, or "I had a reason" style calls — the reviewer's judgment wins on close calls.
+- **PUSHED_BACK** requires a concrete, specific, *verifiable* reason the reviewer could not have known (e.g. "this branch is required because caller X passes nil during reload — see file.lua:123", "this helper has a second caller in file Y the reviewer missed"). "I disagree", "I prefer the original", "this is a matter of style", or "the reviewer's alternative is also fine" are **not** valid pushbacks. When in doubt, fix it.
+- **DEFERRED** is only for findings clearly out of the current task's scope. If a finding is in scope, you either FIX or PUSH_BACK — never defer to avoid the work.
+- For every finding, emit a line in your summary: `- [FIXED|PUSHED_BACK|DEFERRED] — `. Pushbacks must cite a file/line or external constraint.
+
+If a prior pass already pushed back on a finding and the reviewer restated it with a counter-argument, the tie breaks toward FIXED. Two rounds of reviewer insistence override your original objection unless you can add *new* information the reviewer has not yet addressed.
+
+## Boundaries
+
+- Do not open PRs, push branches, or run destructive git commands unless explicitly asked.
+- Do not add compatibility shims, fallback paths, or defensive wrappers beyond what the task requires.
+- Do not extract helpers for single-use code or invent abstractions "for the future."
+- Do not modify unrelated files to satisfy personal style preferences.
diff --git a/.github/agents/Iterate.agent.md b/.github/agents/Iterate.agent.md
new file mode 100644
index 00000000..f133d8f6
--- /dev/null
+++ b/.github/agents/Iterate.agent.md
@@ -0,0 +1,109 @@
+---
+name: Iterate
+description: Orchestrates an implement-then-review loop. Delegates implementation to the default agent (GPT-5.4) and review to LuaReview, iterating until the review is clean or two review cycles have completed.
+argument-hint: A task to implement (e.g. "add X to module Y", "refactor Z").
+tools: [agent, todo]
+model: GPT-5.4 (copilot)
+---
+
+You are an orchestrator. You do not write code or review code yourself. You delegate every step to a subagent via `runSubagent` and relay results.
+
+## Loop
+
+Maintain state across the loop:
+
+- **TASK** — the original user task, verbatim.
+- **LEDGER** — an append-only history of every pass so far. Each entry records the phase, what was produced, and (for Developer entries) the per-finding disposition. Passed to every subagent so later steps don't repeat earlier work or revisit settled questions.
+- **LAST_CHANGESET** — the most recent Developer summary.
+- **LAST_REVIEW** — the most recent reviewer findings, or `CLEAN`.
+
+Execute at most **3 implementation passes** and **2 review cycles**:
+
+1. **Implement (pass 1)** — `runSubagent` `agentName: "Developer"` with the Developer envelope. LEDGER is empty.
+2. Append a Developer entry to the LEDGER (see format below).
+3. **Review (cycle 1)** — `runSubagent` `agentName: "LuaReview"` with the Reviewer envelope, scoped to LAST_CHANGESET.
+4. Append a Reviewer entry to the LEDGER.
+5. If LAST_REVIEW is `CLEAN`, stop and report success.
+6. **Implement (pass 2)** — Developer envelope, including LAST_REVIEW and the full LEDGER.
+7. Append Developer entry.
+8. **Review (cycle 2)** — Reviewer envelope, scoped to pass-2 CHANGESET, with the full LEDGER.
+9. Append Reviewer entry.
+10. If LAST_REVIEW is `CLEAN`, stop.
+11. **Implement (pass 3, final)** — Developer envelope with cycle-2 REVIEW and full LEDGER. **Do not run a third review.**
+12. Report final state: the LEDGER, the last REVIEW, and any findings deliberately left unaddressed.
+
+## LEDGER format
+
+The LEDGER is a markdown document built up over the run. Each entry is a level-3 heading with a structured body.
+
+```
+### Pass 1 — Developer
+Files changed:
+- —
+Key decisions:
+Validation:
+Finding responses:
+Known gaps:
+
+### Cycle 1 — Reviewer
+Result:
+Findings (each with an ID for later reference):
+- F1.1 [correctness] —
+- F1.2 [arch] —
+...
+```
+
+When building the LEDGER, assign stable IDs to every finding (`F.`). The Developer must reference those IDs verbatim when responding. Later reviewer cycles must also reference prior IDs when restating or dropping findings.
+
+## Developer envelope
+
+Pass this as the subagent prompt verbatim, filling each section:
+
+```
+## TASK
+
+
+## LEDGER (prior iterations)
+
+
+## REVIEW FINDINGS TO ADDRESS
+
+
+## INSTRUCTIONS
+- Read the LEDGER before acting. Do not redo work already marked FIXED. Do not reintroduce code a prior pass removed. Do not relitigate findings the reviewer already accepted as resolved.
+- Implement the task (pass 1) or address each open finding (pass 2+).
+- For every finding, respond by ID with one of: FIXED (describe the change), PUSHED_BACK (concrete, verifiable reason), or DEFERRED (out of scope, state why).
+- Give the reviewer's findings the benefit of the doubt: prefer FIXED unless you have a specific, defensible reason to push back.
+- Run validation (busted Tests, relevant library suites, luacheck . -q) and report pass/fail.
+- Return the standard Developer summary in a shape the orchestrator can append to the LEDGER.
+```
+
+## Reviewer envelope
+
+```
+## TASK
+
+
+## LEDGER (prior iterations)
+
+
+## CHANGESET TO REVIEW
+
+
+## INSTRUCTIONS
+- Read the LEDGER before reviewing. Do not re-file findings the Developer already FIXED (unless the claimed fix is incorrect — then file a new finding referencing the old ID). Do not raise new issues about code that was not changed in this pass unless the current changes expose them.
+- For each finding from the prior cycle that the Developer PUSHED_BACK or DEFERRED, evaluate the reasoning. Either accept it (drop the finding, note it in the summary) or restate it with a counter-argument and a new finding ID.
+- Review ONLY the changes in this CHANGESET. Do not audit unrelated code.
+- If there are no actionable findings, respond with exactly `CLEAN` on its own line and stop.
+- Otherwise, produce the standard LuaReview output with stable finding IDs (F.).
+```
+
+## Rules
+
+- Always delegate via `runSubagent`. Do not read, edit, search, or run commands yourself.
+- Never paraphrase the TASK. Pass it verbatim every time.
+- Always pass the full LEDGER to every subagent after pass 1. Do not summarize or truncate it — the whole point is that subagents see exactly what prior passes produced.
+- Detect `CLEAN` as a whole-line token, not as a substring match inside prose.
+- Do not exceed 2 review cycles even if findings remain.
+- Track progress with the todo tool so the user can see which phase is active.
+- Be terse in your own narration between steps. The subagents produce the substance.
diff --git a/.github/agents/LuaReview.agent.md b/.github/agents/LuaReview.agent.md
index 3fd81de1..0c3cede8 100644
--- a/.github/agents/LuaReview.agent.md
+++ b/.github/agents/LuaReview.agent.md
@@ -6,7 +6,7 @@ model: Claude Opus 4.7 (copilot)
tools: [vscode/memory, vscode/resolveMemoryFileUri, read, agent, search, oraios/serena/activate_project, oraios/serena/check_onboarding_performed, oraios/serena/edit_memory, oraios/serena/find_referencing_symbols, oraios/serena/find_symbol, oraios/serena/get_current_config, oraios/serena/get_symbols_overview, oraios/serena/initial_instructions, oraios/serena/list_memories, oraios/serena/onboarding, oraios/serena/read_memory, oraios/serena/rename_memory, oraios/serena/write_memory, todo]
---
-You are a senior WoW addon code reviewer. You read code carefully and report honestly. You do not rewrite code unless explicitly asked — your job is to find problems and explain them with enough context that the author can fix them.
+You are a senior WoW addon code reviewer. You read code carefully and report honestly. Use #tool:agent/runSubagent . You do not rewrite code unless explicitly asked — your job is to find problems and explain them with enough context that the author can fix them.
## Operating principles
From 3be35b605bceb27e6e13e8c7113408fb0c03a628 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:15:38 +1000
Subject: [PATCH 21/53] use opus
---
.github/agents/Iterate.agent.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/agents/Iterate.agent.md b/.github/agents/Iterate.agent.md
index f133d8f6..68718bf9 100644
--- a/.github/agents/Iterate.agent.md
+++ b/.github/agents/Iterate.agent.md
@@ -3,7 +3,7 @@ name: Iterate
description: Orchestrates an implement-then-review loop. Delegates implementation to the default agent (GPT-5.4) and review to LuaReview, iterating until the review is clean or two review cycles have completed.
argument-hint: A task to implement (e.g. "add X to module Y", "refactor Z").
tools: [agent, todo]
-model: GPT-5.4 (copilot)
+model: Claude Opus 4.7 (copilot)
---
You are an orchestrator. You do not write code or review code yourself. You delegate every step to a subagent via `runSubagent` and relay results.
From 3e08f7b4afc5e363cf40c7bf90239598a2e048f5 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:15:43 +1000
Subject: [PATCH 22/53] phase 6
---
Libs/LibSettingsBuilder/Controls/Base.lua | 2 +-
.../Controls/Collections.lua | 2 +-
Libs/LibSettingsBuilder/Controls/Rows.lua | 8 +--
Libs/LibSettingsBuilder/Core.lua | 50 ++++++-------------
Libs/LibSettingsBuilder/Tests/Core_spec.lua | 16 +++---
Tests/TestHelpers.lua | 17 ++++---
6 files changed, 38 insertions(+), 57 deletions(-)
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index 5433caa7..7343b891 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -164,7 +164,7 @@ function lib.Input(self, spec)
}
local extent = spec.resolveText and 46 or 26
- local initializer = createCustomListRowInitializer(internal.INPUTROW_TEMPLATE, data, extent, applyInputRowFrame)
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", data, extent, applyInputRowFrame)
local originalInitFrame = initializer.InitFrame
local originalResetter = initializer.Resetter
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index 7f9725cc..b1fbe9bc 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -21,7 +21,7 @@ function internal.createCollectionInitializer(self, spec, errorPrefix)
data.preset = data.variant
end
- local initializer = createCustomListRowInitializer(internal.EMBED_CANVAS_TEMPLATE, data, spec.height, applyCollectionFrame)
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", data, spec.height, applyCollectionFrame)
initializer._lsbEnabled = true
initializer.SetEnabled = function(controlInitializer, enabled)
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 3b846ae7..796131bf 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -44,7 +44,7 @@ function lib.PageActions(self, spec)
local categoryName = self._subcategoryNames[category]
or (category == self._rootCategory and self._rootCategoryName)
or ""
- local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", {
_lsbKind = "pageActions",
name = spec.name or categoryName,
actions = spec.actions,
@@ -59,7 +59,7 @@ function lib.PageActions(self, spec)
end
function lib.Subheader(self, spec)
- local initializer = createCustomListRowInitializer(internal.SUBHEADER_TEMPLATE, {
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", {
_lsbKind = "subheader",
name = spec.name,
}, 28, applySubheaderFrame)
@@ -67,7 +67,7 @@ function lib.Subheader(self, spec)
end
function lib.InfoRow(self, spec)
- local initializer = createCustomListRowInitializer(internal.INFOROW_TEMPLATE, {
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", {
_lsbKind = "infoRow",
name = spec.name,
value = spec.value,
@@ -86,7 +86,7 @@ function lib.EmbedCanvas(self, canvas, height, spec)
local modifiers = copyMixin({}, spec)
modifiers.canvas = canvas
- local initializer = createCustomListRowInitializer(internal.EMBED_CANVAS_TEMPLATE, {
+ local initializer = createCustomListRowInitializer("SettingsListElementTemplate", {
_lsbKind = "embedCanvas",
canvas = canvas,
}, height or canvas:GetHeight(), applyEmbedCanvasFrame)
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 0be98c36..d969b9cd 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -13,19 +13,12 @@ if not lib then
end
lib._loadState = { open = true }
-lib._internal = lib._internal or {}
+lib._internal = {}
+lib._pageLifecycleCallbacks = {}
+lib._pageLifecycleHooked = false
local internal = lib._internal
-internal.EMBED_CANVAS_TEMPLATE = "SettingsListElementTemplate"
-internal.SUBHEADER_TEMPLATE = "SettingsListElementTemplate"
-internal.INFOROW_TEMPLATE = "SettingsListElementTemplate"
-internal.INPUTROW_TEMPLATE = "SettingsListElementTemplate"
-internal.SCROLL_DROPDOWN_TEMPLATE = "SettingsDropdownControlTemplate"
-
-lib._pageLifecycleCallbacks = lib._pageLifecycleCallbacks or {}
-lib._pageLifecycleHooked = lib._pageLifecycleHooked or false
-
--- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide
--- callbacks registered through the root/section/page API. Defers automatically if
--- SettingsPanel has not been created yet (Blizzard_Settings loads on demand).
@@ -379,18 +372,17 @@ end
-- Indent per level: 15
--------------------------------------------------------------------------------
-internal.CanvasLayoutDefaults = internal.CanvasLayoutDefaults
- or {
- elementHeight = 26,
- headerHeight = 50,
- labelX = 37,
- controlCenterX = -80,
- buttonCenterX = -40,
- buttonWidth = 200,
- sliderWidth = 250,
- swatchCenterX = DEFAULT_SWATCH_CENTER_X,
- verifiedPatch = "Retail 12.0/12.1",
- }
+internal.CanvasLayoutDefaults = {
+ elementHeight = 26,
+ headerHeight = 50,
+ labelX = 37,
+ controlCenterX = -80,
+ buttonCenterX = -40,
+ buttonWidth = 200,
+ sliderWidth = 250,
+ swatchCenterX = DEFAULT_SWATCH_CENTER_X,
+ verifiedPatch = "Retail 12.0/12.1",
+}
local CanvasLayout = {}
internal.CanvasLayout = CanvasLayout
@@ -1059,17 +1051,3 @@ internal.getCanvasLayoutMetrics = getCanvasLayoutMetrics
internal.defaultSwatchCenterX = DEFAULT_SWATCH_CENTER_X
internal.createHeaderTitle = createHeaderTitle
internal.createSubheaderTitle = createSubheaderTitle
-
--- Clear stale keys from prior MINOR versions (LibStub reuses the same table)
-lib.CanvasLayout = nil
-lib.CanvasLayoutDefaults = nil
-lib.CreateCanvasLayout = nil
-lib.CreateColorSwatch = nil
-lib.CreateHeaderTitle = nil
-lib.CreateSubheaderTitle = nil
-lib.EMBED_CANVAS_TEMPLATE = nil
-lib.INFOROW_TEMPLATE = nil
-lib.INPUTROW_TEMPLATE = nil
-lib.SCROLL_DROPDOWN_TEMPLATE = nil
-lib.SUBHEADER_TEMPLATE = nil
-lib.LSBDeprecated = nil
diff --git a/Libs/LibSettingsBuilder/Tests/Core_spec.lua b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
index 5a50d284..8819974a 100644
--- a/Libs/LibSettingsBuilder/Tests/Core_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Core_spec.lua
@@ -33,17 +33,17 @@ describe("LibSettingsBuilder Core", function()
it("loads the split library through the shared ordered loader", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
assert.is_table(lsb)
- assert.is_nil(lsb.LSBDeprecated)
- assert.is_nil(lsb.BuilderMixin)
- assert.is_nil(lsb.CanvasLayout)
- assert.is_nil(lsb.CanvasLayoutDefaults)
- assert.is_nil(lsb.CreateCanvasLayout)
- assert.is_nil(lsb.CreateColorSwatch)
- assert.is_nil(lsb.CreateHeaderTitle)
- assert.is_nil(lsb.CreateSubheaderTitle)
assert.is_nil(lsb._loadState.open)
end)
+ it("initializes implementation internals on load", function()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ assert.is_table(lsb._internal)
+ assert.are.equal(26, lsb._internal.CanvasLayoutDefaults.elementHeight)
+ assert.is_table(lsb._pageLifecycleCallbacks)
+ assert.is_false(lsb._pageLifecycleHooked)
+ end)
+
it("exposes only the public API on builder instances", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local sb = lsb.New({
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 5b25681f..cca6c757 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -129,20 +129,23 @@ end
--- Setup a minimal LibStub stub for tests.
function TestHelpers.SetupLibStub()
local libs = {}
+ local minors = {}
local LibStub = {
NewLibrary = function(self, major, minor)
- if not libs[major] or libs[major].minor < minor then
- libs[major] = { minor = minor, lib = {} }
- return libs[major].lib
+ local oldMinor = minors[major]
+ if oldMinor and oldMinor >= minor then
+ return nil
end
- return nil
+ minors[major] = minor
+ libs[major] = libs[major] or {}
+ return libs[major], oldMinor
end,
}
setmetatable(LibStub, {
__call = function(self, major, silent)
- local entry = libs[major]
- if entry then
- return entry.lib
+ local lib = libs[major]
+ if lib then
+ return lib
end
if not silent then
error("Library not found: " .. major)
From ce333246102233779174bfc8da23dfa88e8e7538 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 17 Apr 2026 16:53:04 +1000
Subject: [PATCH 23/53] final phase
---
.github/agents/Iterate.agent.md | 4 +
Libs/LibSettingsBuilder/Core.lua | 63 +++-
Libs/LibSettingsBuilder/README.md | 27 +-
Libs/LibSettingsBuilder/Utility.lua | 308 ++++++++++++++++
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 331 +++++++++++++-----
Libs/LibSettingsBuilder/docs/INSTALLATION.md | 11 +-
.../docs/MIGRATION_GUIDE.md | 9 +
Libs/LibSettingsBuilder/docs/QUICK_START.md | 6 +-
.../docs/TROUBLESHOOTING.md | 33 +-
9 files changed, 658 insertions(+), 134 deletions(-)
diff --git a/.github/agents/Iterate.agent.md b/.github/agents/Iterate.agent.md
index 68718bf9..da367c72 100644
--- a/.github/agents/Iterate.agent.md
+++ b/.github/agents/Iterate.agent.md
@@ -8,6 +8,10 @@ model: Claude Opus 4.7 (copilot)
You are an orchestrator. You do not write code or review code yourself. You delegate every step to a subagent via `runSubagent` and relay results.
+## Phases
+
+If the TASK is organized into explicit phases (e.g. "Phase 1: ...", "Phase 2: ..."), run the loop below once per phase, in the order given. Each phase is a full implement-then-review loop with its own budget (3 implementation passes, 2 review cycles). Do not start phase N+1 until phase N has terminated (either CLEAN or budget exhausted). Carry the LEDGER forward across phases so later phases see the full history; label entries with the phase (e.g. `### Phase 2 — Pass 1 — Developer`). If the TASK has no phases, treat it as a single phase and run the loop once.
+
## Loop
Maintain state across the loop:
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index d969b9cd..45837f36 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -6,6 +6,51 @@
-- World of Warcraft Settings API. Provides proxy controls, composite groups
-- and utility helpers.
+--- Row or builder-level change hook fired after a value is written.
+---@alias LibSettingsBuilderChangedCallback fun(ctx: LibSettingsBuilderCallbackContext, value: any)
+
+--- Row-local post-set hook fired before `config.onChanged`.
+---@alias LibSettingsBuilderRowSetCallback fun(ctx: LibSettingsBuilderCallbackContext, value: any)
+
+--- Page lifecycle hook fired when Blizzard shows or hides a registered page.
+---@alias LibSettingsBuilderPageLifecycleCallback fun()
+
+--- Custom nested-path getter used by path-bound rows.
+---@alias LibSettingsBuilderGetNestedValue fun(tbl: table, path: string): any
+
+--- Custom nested-path setter used by path-bound rows.
+---@alias LibSettingsBuilderSetNestedValue fun(tbl: table, path: string, value: any)
+
+--- Callback context passed to row callbacks and `config.onChanged`.
+---@class LibSettingsBuilderCallbackContext
+---@field builder LibSettingsBuilderRuntime Gets the runtime instance that owns the registered page tree.
+---@field category table Gets the Blizzard Settings category backing the active row.
+---@field key string|number|nil Gets the handler-mode key for rows registered through `key`.
+---@field page LibSettingsBuilderPageHandle|nil Gets the registered page handle that owns the row, when available.
+---@field path string|nil Gets the resolved path used by path-bound rows.
+---@field setting table|nil Gets the proxy setting object for persisted row kinds.
+---@field spec LibSettingsBuilderRowConfig Gets the normalized row spec that triggered the callback.
+
+--- Root registration config passed to `LSB.New(...)`.
+--- Example (root page):
+--- local lsb = LSB.New({
+--- name = "My Addon",
+--- onChanged = function(ctx) MyAddon:Refresh() end,
+--- page = {
+--- key = "about",
+--- rows = { { type = "info", name = "Version", value = AddOnVersion } },
+--- },
+--- })
+---@class LibSettingsBuilderConfig
+---@field name string|nil Gets the root category display name.
+---@field onChanged LibSettingsBuilderChangedCallback Gets the callback fired after a row setter completes.
+---@field store table|(fun(): table)|nil Gets the store table or lazy provider used by path-bound rows.
+---@field defaults table|(fun(): table)|nil Gets the defaults table or lazy provider used by path-bound rows.
+---@field getNestedValue LibSettingsBuilderGetNestedValue|nil Gets the custom nested-path reader used by path-bound rows.
+---@field setNestedValue LibSettingsBuilderSetNestedValue|nil Gets the custom nested-path writer used by path-bound rows.
+---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition.
+---@field sections LibSettingsBuilderSectionConfig[]|nil Gets the optional section definitions registered under the root category.
+
local MAJOR, MINOR = "LibSettingsBuilder-1.0", 3
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
@@ -681,6 +726,8 @@ function internal.makeVarName(self, spec)
return self._config.varPrefix .. "_" .. tostring(id):gsub("%.", "_")
end
+---@param self LibSettingsBuilderRuntime
+---@param spec LibSettingsBuilderRowConfig|table
function internal.createCallbackContext(self, spec, setting)
return {
builder = self,
@@ -962,15 +1009,13 @@ end
internal.defaultSliderFormatter = defaultSliderFormatter
---- Create a new LibSettingsBuilder runtime instance.
----@param config table
---- Required fields:
---- onChanged function(ctx, value) called after each setter
---- Optional fields:
---- name string root category display name for declarative registration
---- store table nested config store for path-bound rows
---- defaults table nested defaults table for path-bound rows
----@return table builder instance with the full SB API
+--- Creates a LibSettingsBuilder runtime instance and optionally registers the full declarative tree.
+--- `config.onChanged(ctx, value)` runs after any row-local `onSet(ctx, value)` hook.
+--- Path-bound rows resolve against `config.store` / `config.defaults`; handler-bound rows use row-local `get`, `set`, and `key` callbacks.
+---@overload fun(config: LibSettingsBuilderConfig): LibSettingsBuilderRuntime
+---@param selfOrConfig LibSettingsBuilderConfig|table
+---@param maybeConfig LibSettingsBuilderConfig|nil
+---@return LibSettingsBuilderRuntime lsb
function lib.New(selfOrConfig, maybeConfig)
local config = maybeConfig or selfOrConfig
assert(type(config) == "table", "LibSettingsBuilder.New: config table is required")
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index 14c5dd65..4c1b2f39 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -17,20 +17,17 @@ It supports:
Distributed via [LibStub](https://www.wowace.com/projects/libstub).
-## v2 status
+## Public surface
-Phases 1 and 2 of the v2 rearchitecture are now in place.
+The documented public surface is the current declarative API:
-That means the target public surface is defined even though the runtime still carries the compatibility APIs used by the current addon code:
+- factory: `LSB.New(config)`
+- runtime lookups: `lsb:GetSection(sectionKey)`, `lsb:GetRootPage()`, `lsb:GetPage(sectionKey, pageKey)`, `lsb:HasCategory(category)`
+- page handle methods: `page:GetId()`, `page:Refresh()`
+- registration root: `config.page` plus `config.sections`
+- canonical registration schema: raw row tables in `rows = { ... }`
-- target factory: `LSB.New(config)`
-- target runtime object: `lsb`
-- target lookups: `lsb:GetSection(sectionKey)`, `lsb:GetRootPage()`, `lsb:GetPage(sectionKey, pageKey)`, `lsb:HasCategory(category)`
-- target page handle: `page:GetId()`, `page:Refresh()`
-- target schema root: `config.page` plus `config.sections`
-- raw row tables are the canonical schema at registration boundaries
-- builder-level row helper constructors are no longer public on `lsb` instances
-- deprecated compatibility namespace: `LSBDeprecated`
+The runtime returned by `LSB.New(...)` is intentionally narrow. Builder/helper constructors are not exposed on `lsb` instances, and deprecated transition namespaces like `LSBDeprecated` are not part of the documented public API.
## At a glance
@@ -40,10 +37,10 @@ That means the target public surface is defined even though the runtime still ca
| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec |
| Dynamic refresh | lookup the registered page with `lsb:GetRootPage()` / `lsb:GetPage(...)`, then call `page:Refresh()` |
| Existing AceDB profiles | `store = db.profile`, `defaults = defaults.profile` |
-| Custom storage | handler mode with `get` / `set` / `key` |
+| Custom storage | handler mode with `get` / `set` / `key` (or `id`) |
| Text entry / numeric ID fields | `type = "input"` |
| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
-| Reusable settings groups | border, font override, positioning composites |
+| Reusable settings groups | border, font override, and height override composites |
| XML-backed bespoke widgets | `type = "custom"` |
| Force visible rows to refresh | `page:Refresh()` |
@@ -99,6 +96,8 @@ local lsb = LSB.New({
})
```
+For a registered category tree, `name` and `onChanged` are required. `store` enables path-bound rows, and `defaults` supplies their default values.
+
## Canonical row types
Declarative pages accept canonical row types only.
@@ -208,7 +207,7 @@ The `.busted` config defines the `libsettingsbuilder` task pointing at this libr
- Embed the library inside your addon's `Libs/` folder.
- Load `LibStub` before `LibSettingsBuilder`.
- Load `Libs\LibSettingsBuilder\embed.xml` rather than the individual library Lua files.
-- Prefer a single `LSB.New({ page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls.
+- Prefer a single `LSB.New({ name = ..., onChanged = ..., page = ..., sections = { ... } })` call and keep page handles only for later `page:Refresh()` calls.
- `page:Refresh()` is the intended way to refresh dynamic info rows, dropdown options, and dynamic list rows after profile mutations, async item loads, or other out-of-band changes.
- Slider value editing and scroll dropdown support are implemented through Settings UI integration hooks.
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 544c0c2e..f9173a9b 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -2,6 +2,302 @@
-- Author: Argium
-- Licensed under the GNU General Public License v3.0
+--- No-arg predicate used by declarative `disabled` and `hidden` fields.
+---@alias LibSettingsBuilderPredicate boolean|fun(): boolean
+
+--- Dropdown value source used by `dropdown` rows.
+---@alias LibSettingsBuilderDropdownValues table|fun(): table
+
+--- Inline slider label formatter used by `slider` rows.
+---@alias LibSettingsBuilderSliderFormatter fun(value: number): string
+
+--- Button row callback.
+---@alias LibSettingsBuilderButtonClickCallback fun(ctx: LibSettingsBuilderCallbackContext)
+
+--- Input preview resolver.
+---@alias LibSettingsBuilderInputResolveTextCallback fun(value: string, setting: table, frame: Frame): string|nil
+
+--- Input text-change hook.
+---@alias LibSettingsBuilderInputTextChangedCallback fun(text: string, setting: table, frame: Frame)
+
+--- Page-actions button callback.
+---@alias LibSettingsBuilderPageActionClickCallback fun(action: LibSettingsBuilderPageActionConfig, frame: Frame)
+
+--- Dynamic flat-list provider.
+---@alias LibSettingsBuilderListItemsProvider fun(frame: Frame): table[]
+
+--- Dynamic grouped-list provider.
+---@alias LibSettingsBuilderSectionListProvider fun(frame: Frame): table[]
+
+--- Canonical declarative row kinds accepted by `config.page.rows` and section page rows.
+---@alias LibSettingsBuilderRowKind
+---| "border"
+---| "button"
+---| "canvas"
+---| "checkbox"
+---| "checkboxList"
+---| "color"
+---| "colorList"
+---| "custom"
+---| "dropdown"
+---| "fontOverride"
+---| "header"
+---| "heightOverride"
+---| "info"
+---| "input"
+---| "list"
+---| "pageActions"
+---| "sectionList"
+---| "slider"
+---| "subheader"
+
+--- Dynamic list presets supported by `type = "list"` rows.
+---@alias LibSettingsBuilderListVariant
+---| "editor"
+---| "swatch"
+
+--- Registered section metadata returned by `lsb:GetSection(...)`.
+---@class LibSettingsBuilderSectionHandle
+---@field key string Gets the stable section key.
+---@field name string Gets the section display name.
+---@field path string Gets the base path prefix applied to child pages and rows.
+
+--- Plain page handle returned by `lsb:GetRootPage()` and `lsb:GetPage(...)`.
+---@class LibSettingsBuilderPageHandle
+---@field GetId fun(self: LibSettingsBuilderPageHandle): string Gets the Blizzard Settings category ID for this registered page.
+---@field Refresh fun(self: LibSettingsBuilderPageHandle) Refreshes visible rows and dynamic content for this registered page.
+
+--- Runtime object returned by `LSB.New(...)`.
+---@class LibSettingsBuilderRuntime
+---@field GetSection fun(self: LibSettingsBuilderRuntime, key: string): LibSettingsBuilderSectionHandle|nil Gets the registered section metadata by key.
+---@field GetRootPage fun(self: LibSettingsBuilderRuntime): LibSettingsBuilderPageHandle|nil Gets the registered root page handle.
+---@field GetPage fun(self: LibSettingsBuilderRuntime, sectionKey: string, pageKey: string): LibSettingsBuilderPageHandle|nil Gets the registered section page handle by section and page key.
+---@field HasCategory fun(self: LibSettingsBuilderRuntime, category: table|nil): boolean Gets whether this runtime owns the supplied Blizzard Settings category.
+
+--- Declarative page definition registered under the root category or a section.
+---@class LibSettingsBuilderPageConfig
+---@field key string Gets the stable page key within its owner.
+---@field name string|nil Gets the page display name; defaults to the root or section name when omitted.
+---@field path string|nil Gets the optional base path prefix prepended to child path-bound rows.
+---@field rows LibSettingsBuilderRowConfig[] Gets the declarative row array registered on the page.
+---@field onShow LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard shows this page.
+---@field onHide LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard hides this page.
+---@field disabled LibSettingsBuilderPredicate|nil Gets the page-level disabled predicate propagated to child rows.
+---@field hidden LibSettingsBuilderPredicate|nil Gets the page-level hidden predicate propagated to child rows.
+---@field order number|nil Gets the sort order used when a section declares multiple pages.
+
+--- Declarative section definition registered under `config.sections`.
+--- Example (section page):
+--- {
+--- key = "general",
+--- name = "General",
+--- pages = {
+--- {
+--- key = "main",
+--- rows = { { type = "checkbox", path = "enabled", name = "Enable" } },
+--- },
+--- },
+--- }
+---@class LibSettingsBuilderSectionConfig
+---@field key string Gets the stable section key.
+---@field name string Gets the section display name.
+---@field path string|nil Gets the optional base path prefix; defaults to `key`.
+---@field order number|nil Gets the sort order among sibling sections.
+---@field pages LibSettingsBuilderPageConfig[] Gets the page definitions registered under this section.
+
+--- Shared fields accepted by all declarative row kinds.
+---@class LibSettingsBuilderRowBase
+---@field type LibSettingsBuilderRowKind Gets the canonical row kind to register.
+---@field id string|number|nil Gets the optional per-page row identifier.
+---@field name string|nil Gets the primary display label when the row kind uses one.
+---@field tooltip string|nil Gets the tooltip text shown for the row or control.
+---@field disabled LibSettingsBuilderPredicate|nil Gets the disabled predicate reevaluated during row refreshes.
+---@field hidden LibSettingsBuilderPredicate|nil Gets the hidden predicate reevaluated during row refreshes.
+
+--- Shared binding fields for persisted row kinds.
+--- Use either path mode (`path`) or handler mode (`key` + `get` + `set`), never both.
+--- Example (path-bound row):
+--- { type = "checkbox", path = "general.enabled", name = "Enable" }
+--- Example (handler-bound row):
+--- {
+--- type = "input",
+--- key = "draftSpellId",
+--- name = "Spell ID",
+--- get = function() return draft.spellIdText end,
+--- set = function(value) draft.spellIdText = value or "" end,
+--- }
+---@class LibSettingsBuilderBindableRowBase: LibSettingsBuilderRowBase
+---@field path string|nil Gets the dot-path resolved against `config.store` and `config.defaults`.
+---@field key string|number|nil Gets the stable handler key used when the row is not path-bound.
+---@field default any Gets the default value used when the binding does not provide one.
+---@field get (fun(): any)|nil Gets the handler-mode getter callback.
+---@field set fun(value: any)|nil Gets the handler-mode setter callback.
+---@field getTransform (fun(value: any): any)|nil Gets the read transform applied before the control sees the stored value.
+---@field setTransform (fun(value: any): any)|nil Gets the write transform applied before the value is stored.
+---@field onSet LibSettingsBuilderRowSetCallback|nil Gets the row-local callback fired before `config.onChanged`.
+
+--- Shared fields for composite rows that always consume a path prefix.
+---@class LibSettingsBuilderPathRowBase: LibSettingsBuilderRowBase
+---@field path string Gets the dot-path prefix consumed by this composite row.
+
+--- Child definition used by `checkboxList` and `colorList` rows.
+---@class LibSettingsBuilderCompositeListDef
+---@field key string|number Gets the child key appended to the parent row path.
+---@field name string Gets the child row label.
+---@field tooltip string|nil Gets the child row tooltip.
+
+--- Action button definition used by `pageActions` rows.
+---@class LibSettingsBuilderPageActionConfig
+---@field name string|nil Gets the fallback button label when `text` is omitted.
+---@field text string|nil Gets the button label.
+---@field width number|nil Gets the button width.
+---@field height number|nil Gets the button height.
+---@field enabled boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the enabled predicate or static enabled flag.
+---@field hidden boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the hidden predicate or static hidden flag.
+---@field tooltip string|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): string|nil)|nil Gets the tooltip text or tooltip resolver.
+---@field onClick LibSettingsBuilderPageActionClickCallback|nil Gets the click callback.
+
+---@class LibSettingsBuilderCheckboxRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "checkbox" Gets the checkbox row kind.
+
+---@class LibSettingsBuilderSliderRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "slider" Gets the slider row kind.
+---@field min number Gets the minimum slider value.
+---@field max number Gets the maximum slider value.
+---@field step number|nil Gets the slider step size.
+---@field formatter LibSettingsBuilderSliderFormatter|nil Gets the inline value formatter.
+
+---@class LibSettingsBuilderDropdownRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "dropdown" Gets the dropdown row kind.
+---@field values LibSettingsBuilderDropdownValues Gets the dropdown value table or provider.
+---@field scrollHeight number|nil Gets the optional scrollable menu height.
+---@field varType any Gets the optional `Settings.VarType` override.
+
+---@class LibSettingsBuilderColorRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "color" Gets the color-swatch row kind.
+
+---@class LibSettingsBuilderInputRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "input" Gets the text-input row kind.
+---@field debounce number|nil Gets the preview debounce in seconds.
+---@field maxLetters number|nil Gets the maximum edit-box length.
+---@field numeric boolean|nil Gets whether the edit box only accepts numeric input.
+---@field onTextChanged LibSettingsBuilderInputTextChangedCallback|nil Gets the callback fired after the new text is written.
+---@field resolveText LibSettingsBuilderInputResolveTextCallback|nil Gets the preview-text resolver shown beneath the edit box.
+---@field width number|nil Gets the edit-box width.
+
+---@class LibSettingsBuilderCustomRowConfig: LibSettingsBuilderBindableRowBase
+---@field type "custom" Gets the XML-template-backed custom row kind.
+---@field template string Gets the XML template name registered with Blizzard's Settings API.
+---@field varType any Gets the optional `Settings.VarType` override.
+
+---@class LibSettingsBuilderButtonRowConfig: LibSettingsBuilderRowBase
+---@field type "button" Gets the button row kind.
+---@field buttonText string|nil Gets the button label; defaults to `name`.
+---@field confirm boolean|string|nil Gets whether the row shows a confirmation dialog, or the confirmation text to use.
+---@field onClick LibSettingsBuilderButtonClickCallback Gets the click callback.
+
+---@class LibSettingsBuilderHeaderRowConfig: LibSettingsBuilderRowBase
+---@field type "header" Gets the header row kind.
+---@field name string Gets the header label.
+
+---@class LibSettingsBuilderSubheaderRowConfig: LibSettingsBuilderRowBase
+---@field type "subheader" Gets the subheader row kind.
+---@field name string Gets the subheader label.
+
+---@class LibSettingsBuilderInfoRowConfig: LibSettingsBuilderRowBase
+---@field type "info" Gets the informational row kind.
+---@field value string|number|boolean|(fun(frame: Frame, data: table): any)|nil Gets the primary value or dynamic value resolver.
+---@field values string[]|nil Gets the optional multiline value array, joined with newlines during normalization.
+---@field wide boolean|nil Gets whether the value should span the full row without a left label.
+---@field multiline boolean|nil Gets whether the value text may wrap across multiple lines.
+---@field height number|nil Gets the custom row height.
+
+---@class LibSettingsBuilderCanvasRowConfig: LibSettingsBuilderRowBase
+---@field type "canvas" Gets the embedded-canvas row kind.
+---@field canvas Frame Gets the prebuilt frame to embed into the settings page.
+---@field height number|nil Gets the embedded row height; defaults to the canvas height.
+
+---@class LibSettingsBuilderPageActionsRowConfig: LibSettingsBuilderRowBase
+---@field type "pageActions" Gets the page-actions row kind.
+---@field actions LibSettingsBuilderPageActionConfig[] Gets the action button definitions attached to the page header.
+---@field height number|nil Gets the placeholder row height used by the initializer.
+
+---@class LibSettingsBuilderListRowConfig: LibSettingsBuilderRowBase
+---@field type "list" Gets the dynamic flat-list row kind.
+---@field height number Gets the total row height reserved for the list widget.
+---@field variant LibSettingsBuilderListVariant|nil Gets the built-in list preset applied to item data.
+---@field items LibSettingsBuilderListItemsProvider Gets the item provider called during refreshes.
+
+---@class LibSettingsBuilderSectionListRowConfig: LibSettingsBuilderRowBase
+---@field type "sectionList" Gets the dynamic grouped-list row kind.
+---@field height number Gets the total row height reserved for the list widget.
+---@field sections LibSettingsBuilderSectionListProvider Gets the section provider called during refreshes.
+
+---@class LibSettingsBuilderCheckboxListRowConfig: LibSettingsBuilderPathRowBase
+---@field type "checkboxList" Gets the checkbox-list composite row kind.
+---@field defs LibSettingsBuilderCompositeListDef[] Gets the child checkbox definitions.
+---@field label string|nil Gets the optional composite subheader label.
+
+---@class LibSettingsBuilderColorListRowConfig: LibSettingsBuilderPathRowBase
+---@field type "colorList" Gets the color-list composite row kind.
+---@field defs LibSettingsBuilderCompositeListDef[] Gets the child color definitions.
+---@field label string|nil Gets the optional composite subheader label.
+
+---@class LibSettingsBuilderBorderRowConfig: LibSettingsBuilderPathRowBase
+---@field type "border" Gets the border composite row kind.
+---@field enabledName string|nil Gets the enable-row label.
+---@field enabledTooltip string|nil Gets the enable-row tooltip.
+---@field thicknessName string|nil Gets the border-width row label.
+---@field thicknessTooltip string|nil Gets the border-width row tooltip.
+---@field thicknessMin number|nil Gets the minimum border width.
+---@field thicknessMax number|nil Gets the maximum border width.
+---@field thicknessStep number|nil Gets the border-width step size.
+---@field colorName string|nil Gets the color-row label.
+---@field colorTooltip string|nil Gets the color-row tooltip.
+
+---@class LibSettingsBuilderFontOverrideRowConfig: LibSettingsBuilderPathRowBase
+---@field type "fontOverride" Gets the font-override composite row kind.
+---@field enabledName string|nil Gets the override toggle label.
+---@field enabledTooltip string|nil Gets the override toggle tooltip.
+---@field fontName string|nil Gets the font-row label.
+---@field fontTooltip string|nil Gets the font-row tooltip.
+---@field fontValues (fun(): table)|nil Gets the optional dropdown value provider for the font row.
+---@field fontFallback (fun(): string|nil)|nil Gets the fallback font name used when no override is stored.
+---@field fontTemplate string|nil Gets the optional custom template used instead of the built-in dropdown.
+---@field sizeName string|nil Gets the font-size row label.
+---@field sizeTooltip string|nil Gets the font-size row tooltip.
+---@field sizeMin number|nil Gets the minimum font size.
+---@field sizeMax number|nil Gets the maximum font size.
+---@field sizeStep number|nil Gets the font-size step size.
+---@field fontSizeFallback (fun(): number|nil)|nil Gets the fallback font size used when no override is stored.
+
+---@class LibSettingsBuilderHeightOverrideRowConfig: LibSettingsBuilderPathRowBase
+---@field type "heightOverride" Gets the height-override composite row kind.
+---@field min number|nil Gets the minimum slider value.
+---@field max number|nil Gets the maximum slider value.
+---@field step number|nil Gets the slider step size.
+
+---@alias LibSettingsBuilderRowConfig
+---| LibSettingsBuilderCheckboxRowConfig
+---| LibSettingsBuilderSliderRowConfig
+---| LibSettingsBuilderDropdownRowConfig
+---| LibSettingsBuilderColorRowConfig
+---| LibSettingsBuilderInputRowConfig
+---| LibSettingsBuilderCustomRowConfig
+---| LibSettingsBuilderButtonRowConfig
+---| LibSettingsBuilderHeaderRowConfig
+---| LibSettingsBuilderSubheaderRowConfig
+---| LibSettingsBuilderInfoRowConfig
+---| LibSettingsBuilderCanvasRowConfig
+---| LibSettingsBuilderPageActionsRowConfig
+---| LibSettingsBuilderListRowConfig
+---| LibSettingsBuilderSectionListRowConfig
+---| LibSettingsBuilderCheckboxListRowConfig
+---| LibSettingsBuilderColorListRowConfig
+---| LibSettingsBuilderBorderRowConfig
+---| LibSettingsBuilderFontOverrideRowConfig
+---| LibSettingsBuilderHeightOverrideRowConfig
+
local MAJOR = "LibSettingsBuilder-1.0"
local lib = LibStub(MAJOR, true)
if not lib or not lib._loadState or not lib._loadState.open then
@@ -520,15 +816,24 @@ local function createRootPage(root, key, rows, opts)
return page
end
+--- Gets the registered section metadata by key.
+---@param key string
+---@return LibSettingsBuilderSectionHandle|nil section
function lib:GetSection(key)
return self._sections[key]
end
+--- Gets the registered root page handle.
+---@return LibSettingsBuilderPageHandle|nil page
function lib:GetRootPage()
local page = self._registeredRootPage
return page and page._handle or nil
end
+--- Gets the registered section page handle by section and page key.
+---@param sectionKey string
+---@param pageKey string
+---@return LibSettingsBuilderPageHandle|nil page
function lib:GetPage(sectionKey, pageKey)
if pageKey == nil then
return nil
@@ -539,6 +844,9 @@ function lib:GetPage(sectionKey, pageKey)
return page and page._handle or nil
end
+--- Gets whether this runtime owns the supplied Blizzard Settings category.
+---@param category table|nil
+---@return boolean owned
function lib:HasCategory(category)
return category ~= nil and self._layouts[category] ~= nil
end
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index cdc8fa24..a5aa4401 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -8,11 +8,11 @@
- [Migration Guide](MIGRATION_GUIDE.md)
- [Troubleshooting](TROUBLESHOOTING.md)
-## v2 Freeze
+## Current public surface
-Phases 1 and 2 freeze the intended v2 public surface and make raw declarative rows the canonical registration schema.
+`LibSettingsBuilder` is centered on declarative registration through `LSB.New({ ... })`.
-Target v2 surface:
+Documented surface:
- `LSB.New(config)`
- `lsb:GetSection(sectionKey)`
@@ -21,24 +21,20 @@ Target v2 surface:
- `lsb:HasCategory(category)`
- `page:GetId()`
- `page:Refresh()`
-- raw row tables at registration boundaries
-- deprecated compatibility namespace: `LSBDeprecated`
+- `config.page` and `config.sections`
+- raw row tables in `rows = { ... }`
-Builder-level row helper constructors are no longer part of the public `lsb` instance surface. Use raw row tables through `LSB.New({ ... })`.
+The runtime returned by `LSB.New(...)` is intentionally narrow. Row helper constructors are not available on `lsb` instances, and deprecated transition namespaces like `LSBDeprecated` are not part of the documented public API.
-### `LSBDeprecated`
+The declarative loader still normalizes a small compatibility subset of older field names:
-Phase 1 establishes `LSBDeprecated` as the compatibility namespace for APIs that will move off the main `LSB` table in a later phase.
+- `button.value` → `buttonText`
+- `slider.formatValue` → `formatter`
+- `dropdown.maxScrollDisplayHeight` → `scrollHeight`
+- `input.debounceMilliseconds` → `debounce` (seconds)
+- `info.values` → newline-joined `value` plus `multiline = true`
-Currently exposed there:
-
-- `LSBDeprecated.CreateCanvasLayout(...)`
-- `LSBDeprecated.SetCanvasLayoutDefaults(...)`
-- `LSBDeprecated.ConfigureCanvasLayout(...)`
-- `LSBDeprecated.CreateColorSwatch(...)`
-- `LSBDeprecated.CreateHeaderTitle(...)`
-- `LSBDeprecated.CreateSubheaderTitle(...)`
-- `LSBDeprecated.CanvasLayout`
+Removed fields such as `desc`, `condition`, `parent`, and `parentCheck` error at registration time.
## Factory
@@ -46,13 +42,18 @@ Currently exposed there:
Required fields:
-- `name`
- `onChanged(ctx, value)`
+Conditionally required fields:
+
+- `name` — required when registering `page` or `sections`
+
Optional fields:
-- `store`
-- `defaults`
+- `store` — table or function returning the live store used by path-bound rows
+- `defaults` — table or function returning default values for path-bound rows
+- `getNestedValue`
+- `setNestedValue`
- `page`
- `sections`
@@ -74,7 +75,10 @@ Root page definition fields:
- `name` (optional; defaults to the root name)
- `onShow`
- `onHide`
+- `disabled`
+- `hidden`
- `order`
+- `path`
Section definition fields:
@@ -100,27 +104,32 @@ Notes:
- single-page sections flatten to a single leaf by default,
- multi-page sections create a visible section node automatically.
+- page `path` prefixes child `path` fields that do not already contain dots,
+- page-level `disabled` and `hidden` values propagate to child rows unless a row overrides them.
Declarative root registration is the only supported page-construction API.
### Lookup and page operations
-- `lsb:GetSection(key)`
-- `lsb:GetRootPage()`
-- `lsb:GetPage(sectionKey, pageKey)`
-- `lsb:HasCategory(category)`
-- `page:GetId()`
-- `page:Refresh()`
+- `lsb:GetSection(key)` — registered section metadata or `nil`
+- `lsb:GetRootPage()` — root page handle or `nil`
+- `lsb:GetPage(sectionKey, pageKey)` — section page handle or `nil`
+- `lsb:HasCategory(category)` — whether the category belongs to this runtime
+- `page:GetId()` — Blizzard Settings category ID
+- `page:Refresh()` — refreshes visible rows and registered dynamic content
-## Controls
+Page handles are plain runtime lookup objects, not mutable builders.
-All controls support either:
+## Declarative rows
+
+Persisted rows support either:
- **path mode** with `spec.path`, or
-- **handler mode** with `spec.get`, `spec.set`, and `spec.key`.
+- **handler mode** with `spec.get`, `spec.set`, and `spec.key` or `spec.id`.
Common spec fields:
+- `id`
- `name`
- `tooltip`
- `default`
@@ -129,7 +138,8 @@ Common spec fields:
- `getTransform`
- `setTransform`
- `onSet`
-- `layout`
+
+Use `tooltip`, not `desc`.
### `checkbox` row
@@ -146,16 +156,19 @@ Additional fields:
Slider values are editable inline through the displayed value label.
-### `SB.Dropdown(spec)`
+### `dropdown` row
Additional fields:
- `values`
- `scrollHeight`
+- `varType`
Dropdown values are emitted in deterministic order to keep menus stable between sessions.
-### `SB.Color(spec)`
+`maxScrollDisplayHeight` is still normalized to `scrollHeight` for compatibility.
+
+### `color` row
Reads and writes `{ r, g, b, a }` tables through a hex proxy value.
@@ -178,6 +191,8 @@ Notes:
- `resolveText` enables an optional preview line below the edit box,
- `debounce` delays preview recomputation through `C_Timer.NewTimer`.
+`debounceMilliseconds` is still normalized to `debounce / 1000` for compatibility.
+
### `custom` row
Additional fields:
@@ -191,59 +206,234 @@ Notes:
- the template must already be loaded by the time you register settings,
- unlike `input`, `custom` does not create its frame structure in Lua.
-### `SB.Control(spec)`
+### `button` row
+
+Additional fields:
+
+- `buttonText`
+- `confirm`
+- `onClick`
+
+Notes:
+
+- `onClick` is required,
+- `confirm = true` uses the default `"Are you sure?"` prompt,
+- `confirm = "..."` uses your custom confirm text,
+- `value` is still normalized to `buttonText` for compatibility.
+
+### `header` row
+
+Use for a Blizzard-style section header.
+
+Required fields:
+
+- `name`
+
+Notes:
+
+- page header buttons belong in a separate `pageActions` row, not on the `header` row.
+
+### `subheader` row
+
+Use for smaller secondary section text.
+
+Required fields:
+
+- `name`
+
+### `info` row
+
+Displays left-label / right-value informational text.
+
+Additional fields:
+
+- `value`
+- `values`
+- `wide`
+- `multiline`
+- `height`
+
+Notes:
+
+- `value` may be a static value or a function,
+- `name` may also be a function for dynamic labels,
+- `wide = true` hides the left label and lets the value span the row,
+- `values = { ... }` is normalized to a newline-joined `value` and sets `multiline = true`.
+
+### `canvas` row
+
+Embeds a prebuilt frame into the settings page.
+
+Additional fields:
+
+- `canvas`
+- `height`
+
+Notes:
+
+- `canvas` is required,
+- `height` defaults to `canvas:GetHeight()`.
+
+### `pageActions` row
+
+Renders right-aligned page-header action buttons.
+
+Additional fields:
+
+- `actions`
+- `height`
+
+Action fields:
+
+- `name`
+- `text`
+- `width`
+- `height`
+- `enabled`
+- `hidden`
+- `tooltip`
+- `onClick`
-Dispatches to the correct control factory using `spec.type`.
+Notes:
+
+- `actions` is required,
+- `enabled`, `hidden`, and `tooltip` may be static values or functions evaluated during refreshes.
-### `SB.List(spec)`
+### `list` row
Creates a first-class dynamic flat list row backed by the normal settings list.
Required fields:
- `height`
+- `items(frame)`
Flat-list fields:
- `variant = "swatch"` or `variant = "editor"`
-- `items(frame)` → item list
+- `rowHeight`
+- `insetLeft`
+- `insetTop`
+- `insetBottom`
+
+Notes:
-### `SB.SectionList(spec)`
+- the row's `variant` becomes the default preset for returned items,
+- `swatch` rows support label/icon/swatch style entries,
+- `editor` rows support label + slider field(s), optional swatch, and a remove button.
+
+### `sectionList` row
Creates a first-class grouped dynamic list row backed by the normal settings list.
-Sectioned-list fields:
+Required fields:
+- `height`
- `sections(frame)` → section list
+Section-level fields commonly used by the built-in renderer:
+
+- `key`
+- `name`
+- `title`
+- `items`
+- `emptyText`
+- `headerHeight`
+- `emptyHeight`
+- `rowHeight`
+- `footer`
+- `footerHeight`
+- `spacingAfter`
+
Supported list variants:
- `swatch` — label/icon plus color swatch rows
- `editor` — label plus one or more slider fields, optional swatch, and remove button
- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`)
+- section action buttons may use text or `buttonTextures = { normal, pushed?, disabled?, highlight?, highlightAlpha?, disabledAlpha? }`
- section trailers support `type = "modeInput"` for toggle + input + preview + submit rows
Mode-input trailer display fields may be static values or functions that are re-evaluated during in-place row refreshes.
-## Composite builders
+## Composite row types
-- `SB.HeightOverrideSlider(sectionPath[, spec])`
-- `SB.FontOverrideGroup(sectionPath[, spec])`
-- `SB.BorderGroup(borderPath[, spec])`
-- `SB.ColorPickerList(basePath, defs[, spec])`
-- `checkboxList`
+These row kinds expand into multiple child rows during registration.
-## Utility helpers
+### `heightOverride`
-- `SB.Header(text[, category])`
-- `SB.Subheader(spec)`
-- `SB.InfoRow(spec)`
-- `canvas`
-- `SB.Button(spec)`
-- `SB.PageActions(spec)`
+Fields:
+
+- `path`
+- `name`
+- `tooltip`
+- `min`
+- `max`
+- `step`
+
+Notes:
+
+- stores `nil` when the slider is set to `0`,
+- reads `nil` back as `0`.
+
+### `fontOverride`
+
+Fields:
+
+- `path`
+- `enabledName`
+- `enabledTooltip`
+- `fontName`
+- `fontTooltip`
+- `fontValues`
+- `fontFallback`
+- `fontTemplate`
+- `sizeName`
+- `sizeTooltip`
+- `sizeMin`
+- `sizeMax`
+- `sizeStep`
+- `fontSizeFallback`
+
+Notes:
+
+- expands to an override checkbox, a font selector, and a size slider,
+- when `fontTemplate` is present, the font selector uses `type = "custom"` instead of the built-in dropdown.
+
+### `border`
+
+Fields:
+
+- `path`
+- `enabledName`
+- `enabledTooltip`
+- `thicknessName`
+- `thicknessTooltip`
+- `thicknessMin`
+- `thicknessMax`
+- `thicknessStep`
+- `colorName`
+- `colorTooltip`
+
+Notes:
+
+- expands to an enable checkbox, a width slider, and a color swatch.
+
+### `colorList` / `checkboxList`
+
+Required fields:
+
+- `defs`
+
+Fields:
+
+- `path`
+- `defs = { { key, name, tooltip? }, ... }`
+- `label`
+
+Notes:
+
+- `defs` is required for both composite row types,
+- `label`, when present, inserts a subheader above the generated child rows.
-`SB.Button` supports `confirm = true` or a custom confirm string. Confirm dialogs are registered per button to avoid cross-button collisions.
-`SB.PageActions` renders right-aligned page-header action buttons.
-`SB.InfoRow` accepts function-backed `value` for dynamic text.
## Declarative page rows
Supported canonical row types:
@@ -282,38 +472,7 @@ The library has three main families of row builders:
- **composites** — helpers that emit multiple child rows (`border`, `fontOverride`, `heightOverride`, `colorList`, `checkboxList`).
`input` is implemented as a built-in custom list row on `SettingsListElementTemplate`. It creates an `InputBoxTemplate` edit box at runtime, subscribes to watched proxy settings through callback handles, and optionally debounces preview refreshes. That gives it built-in-row behavior without requiring a separate XML template.
-`canvas` rows stay on the current lifecycle path. Keep using `type = "canvas"` rows for bespoke frames when a built-in row is not enough.
-
-#### `LSBDeprecated.SetCanvasLayoutDefaults(overrides)`
-
-Merges overrides into the shared defaults table.
-
-#### `SB.ConfigureCanvasLayout(layout, overrides)`
-
-Clones the shared defaults and applies overrides only to the supplied layout.
-
-Useful fields include:
-
-- `elementHeight`
-- `headerHeight`
-- `labelX`
-- `controlCenterX`
-- `buttonCenterX`
-- `buttonWidth`
-- `sliderWidth`
-- `swatchCenterX`
-- `verifiedPatch`
-
-Example:
-
-```lua
-local layout = SB.CreateCanvasLayout("Spell Colors")
-SB.ConfigureCanvasLayout(layout, {
- elementHeight = 30,
- labelX = 42,
- buttonWidth = 220,
-})
-```
+`canvas` rows stay on the current lifecycle path. The documented public canvas API is the `canvas` row type; older canvas-layout helpers live under internal implementation details and are not part of the public surface documented here.
## Debugging
diff --git a/Libs/LibSettingsBuilder/docs/INSTALLATION.md b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
index 11ba9c63..afcc0a12 100644
--- a/Libs/LibSettingsBuilder/docs/INSTALLATION.md
+++ b/Libs/LibSettingsBuilder/docs/INSTALLATION.md
@@ -4,7 +4,7 @@
- [README](../README.md) — overview and quick links
- [Quick Start](QUICK_START.md) — common setup patterns
-- [API Reference](API_REFERENCE.md) — builder, controls, composites, page registration, canvas helpers
+- [API Reference](API_REFERENCE.md) — public surface, row types, page registration, compatibility notes
- [Migration Guide](MIGRATION_GUIDE.md) — moving from AceConfig/AceGUI
- [Troubleshooting](TROUBLESHOOTING.md) — common issues and fixes
@@ -69,11 +69,10 @@ Only `type = "custom"` rows require you to supply your own template. In that cas
2. load that XML from your TOC before calling `LSB.New({ ... })`, and
3. pass the template name through `spec.template`.
-## Canvas layout compatibility
+## Canvas support
-Canvas layout spacing defaults are still available for older `CreateCanvasLayout(...)` pages. New `canvas` rows stay on the current lifecycle path, so canvas content continues to reuse the existing frame handling without special-case rewrites.
+The documented public API for embedded custom content is the `canvas` row type.
-- per-library via `LSBDeprecated.SetCanvasLayoutDefaults(overrides)`
-- per-layout via `LSBDeprecated.ConfigureCanvasLayout(layout, overrides)`
+Build the frame yourself, then register it through `type = "canvas"` with an explicit `height` when needed.
-See [API Reference](API_REFERENCE.md) for examples.
+Older canvas-layout helpers live under internal implementation details and are not part of the documented library surface.
diff --git a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
index ec292217..9b1dd612 100644
--- a/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
+++ b/Libs/LibSettingsBuilder/docs/MIGRATION_GUIDE.md
@@ -42,6 +42,14 @@ local lsb = LSB.New({
})
```
+## Field name updates
+
+When moving old option tables over:
+
+- use `tooltip`, not AceConfig's `desc`,
+- replace removed fields like `condition`, `parent`, and `parentCheck` with `disabled` / `hidden` predicates on rows or pages,
+- prefer the current field names `formatter`, `scrollHeight`, and `debounce` even though the declarative loader still normalizes a few older aliases for compatibility.
+
## Canonical row types
Declarative pages use canonical row types only:
@@ -51,6 +59,7 @@ Declarative pages use canonical row types only:
- `dropdown`
- `input`
- `color`
+- `custom`
- `button`
- `header`
- `subheader`
diff --git a/Libs/LibSettingsBuilder/docs/QUICK_START.md b/Libs/LibSettingsBuilder/docs/QUICK_START.md
index 3f074f5f..8fb53537 100644
--- a/Libs/LibSettingsBuilder/docs/QUICK_START.md
+++ b/Libs/LibSettingsBuilder/docs/QUICK_START.md
@@ -70,6 +70,8 @@ local lsb = LSB.New({
})
```
+`name` and `onChanged` are required when you register a root page or section tree. `store` enables path mode; use handler mode when your values do not live in a dot-path table.
+
Declarative pages can mix persisted controls and layout-only rows freely, so it is normal to combine `checkbox`, `slider`, `input`, `header`, `subheader`, `info`, `button`, `pageActions`, `list`, `sectionList`, and `canvas` entries on one page.
## Handler mode
@@ -120,12 +122,14 @@ local lsb = LSB.New({
})
```
+Handler rows require `get`, `set`, and a stable `key` (or `id`).
+
## Good defaults for public addons
- Pick a stable `name`; the library derives its internal variable prefix from that.
- Point `store` and `defaults` at live tables.
- Keep `onChanged` fast; use it to refresh UI, not rebuild the world.
-- Use composites for repeated patterns like borders, font overrides, and positioning.
+- Use composites for repeated patterns like borders, font overrides, and height overrides.
- Prefer declarative root registration for large standard settings pages.
- Look up registered page handles with `lsb:GetRootPage()` or `lsb:GetPage(...)`, then call `page:Refresh()` for async or transient redraws.
- Reach for `type = "custom"` or `type = "canvas"` only when built-ins like `input`, `list`, and `sectionList` stop fitting.
diff --git a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
index d6c0fdbc..4a09e3df 100644
--- a/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
+++ b/Libs/LibSettingsBuilder/docs/TROUBLESHOOTING.md
@@ -20,16 +20,24 @@ Check the path binding config first.
Common causes:
+- you created `LSB.New({ ... })` with `page` or `sections` but no `name`,
- you created the builder without `store` / `defaults`,
- a spec mixes `path` with `get` / `set`,
-- handler mode is missing `key`.
+- handler mode is missing `key` or `id`.
+
+## Registration fails with deprecated or removed field errors
+
+Common fixes:
+
+- rename `desc` to `tooltip`,
+- replace removed fields like `condition`, `parent`, and `parentCheck` with `disabled` / `hidden`,
+- use `pageActions` for page-header buttons instead of attaching actions to a `header` row.
## Settings page exists but nothing appears in-game
Usually one of these:
- you created `LSB.New({ name = "My Addon", ... })` without a `page` or `sections` tree,
-- you created `LSB.New({ ... })` without `page` or `sections`,
- your registered root page or section page ended up with no visible rows,
- a `hidden` predicate is always returning `true`,
- a `custom` template was never loaded from XML.
@@ -87,24 +95,13 @@ If debugging slider behavior:
- confirm no other addon is replacing the slider frame structure,
- test with other UI customizers disabled.
-## Legacy canvas pages look slightly off after a WoW patch
-
-Canvas layout spacing is configurable for older `CreateCanvasLayout(...)` pages. New pages should prefer the standard settings DSL with `list` or `sectionList` rows instead of tuning canvas metrics by default.
-
-Use:
-
-```lua
-LSBDeprecated.SetCanvasLayoutDefaults({ elementHeight = 28 })
-```
-
-or per layout:
+## Embedded canvas rows look off
-```lua
-local layout = LSBDeprecated.CreateCanvasLayout("My Page")
-LSBDeprecated.ConfigureCanvasLayout(layout, { labelX = 40 })
-```
+`type = "canvas"` embeds the frame you provide. If spacing or clipping looks wrong:
-If Blizzard adjusts Settings panel spacing in a major patch, this is the intended escape hatch.
+- give the row an explicit `height`, or make sure the frame reports a stable height,
+- prefer built-in rows, `list`, or `sectionList` when you want Blizzard-style settings layout instead of a bespoke frame,
+- use `type = "custom"` for XML-backed row widgets rather than a full embedded canvas when you only need one custom control.
## Debugging spec mistakes
From f22682c4ec893f7488de740cf8d1090fab70799e Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 18 Apr 2026 11:19:34 +1000
Subject: [PATCH 24/53] Fix lua errors. Adjust list header styling.
---
.../memories/repo/options-load-order-acedb.md | 2 +
Libs/LibSettingsBuilder/Core.lua | 2 +-
Tests/UI/ProfileOptions_spec.lua | 24 ++++++++++++
UI/ExtraIconsOptions.lua | 38 +++++++++----------
UI/ProfileOptions.lua | 25 ++++++------
5 files changed, 60 insertions(+), 31 deletions(-)
create mode 100644 .serena/memories/repo/options-load-order-acedb.md
diff --git a/.serena/memories/repo/options-load-order-acedb.md b/.serena/memories/repo/options-load-order-acedb.md
new file mode 100644
index 00000000..048e1395
--- /dev/null
+++ b/.serena/memories/repo/options-load-order-acedb.md
@@ -0,0 +1,2 @@
+- UI option spec files can load before `ns.Addon.db` exists; do not read AceDB at chunk load time.
+- Keep profile/option dropdown selections lazy (inside getters/value generators or callbacks), otherwise chunk execution can abort and leave a half-built section table that later fails `Register: each section requires a key`.
\ No newline at end of file
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 45837f36..48ef8058 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -395,7 +395,7 @@ local function createTitle(parent, template, x, y, text, fontObject)
end
local function createSubheaderTitle(parent, text)
- return createTitle(parent, "GameFontHighlightSmall", 35, -8, text, GameFontHighlight)
+ return createTitle(parent, "GameFontNormalSmall", 35, -8, text)
end
local function createHeaderTitle(parent, text)
diff --git a/Tests/UI/ProfileOptions_spec.lua b/Tests/UI/ProfileOptions_spec.lua
index 681241ca..1181beb6 100644
--- a/Tests/UI/ProfileOptions_spec.lua
+++ b/Tests/UI/ProfileOptions_spec.lua
@@ -19,6 +19,30 @@ describe("ProfileOptions getters/setters/defaults", function()
TestHelpers.RestoreGlobals(originalGlobals)
end)
+ it("loads before AceDB initialization and registers once db exists", function()
+ TestHelpers.SetupOptionsGlobals()
+ profile, defaults = TestHelpers.MakeOptionsProfile()
+ SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
+
+ local db = ns.Addon.db
+ ns.Addon.db = nil
+
+ assert.has_no.errors(function()
+ TestHelpers.LoadChunk("UI/ProfileOptions.lua", "ProfileOptions")(nil, ns)
+ end)
+
+ ns.Addon.db = db
+
+ settings = TestHelpers.CollectSettings(function()
+ local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ProfileOptions)
+ profileCategory = page._category
+ end)
+
+ assert.are.equal("profile", ns.ProfileOptions.key)
+ assert.are.equal("Other", settings.ECM_ProfileCopy:GetValue())
+ assert.is_not_nil(profileCategory)
+ end)
+
before_each(function()
TestHelpers.SetupOptionsGlobals()
profile, defaults = TestHelpers.MakeOptionsProfile()
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 4c72e29e..ee98accd 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -24,25 +24,25 @@ ExtraIconsOptions.pages = {
ns.Runtime.SetLayoutPreview(false)
end,
rows = {
- {
- id = "enabled", type = "checkbox", path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"], tooltip = L["ENABLE_EXTRA_ICONS_DESC"],
- onSet = function(ctx, value)
- ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(ctx, value)
- ctx.page:Refresh()
- end,
- },
- {
- id = "specialRowsLegend", type = "info", name = "",
- value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
- wide = true, multiline = true, height = 24,
- },
- {
- id = "viewers", type = "sectionList", height = Util.VIEWER_COLLECTION_HEIGHT,
- disabled = Util.IsDisabled,
- sections = Util.BuildSections,
- onDefault = Util.ResetToDefaults,
- },
+ {
+ id = "enabled", type = "checkbox", path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"], tooltip = L["ENABLE_EXTRA_ICONS_DESC"],
+ onSet = function(ctx, value)
+ ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(ctx, value)
+ ctx.page:Refresh()
+ end,
+ },
+ {
+ id = "specialRowsLegend", type = "info", name = "",
+ value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
+ wide = true, multiline = true, height = 24,
+ },
+ {
+ id = "viewers", type = "sectionList", height = Util.VIEWER_COLLECTION_HEIGHT,
+ disabled = Util.IsDisabled,
+ sections = Util.BuildSections,
+ onDefault = Util.ResetToDefaults,
+ },
},
},
}
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index 2eba09dc..7b1ea3f3 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -71,12 +71,14 @@ end
--- Creates a handler-backed dropdown for transient profile selection (not stored in SavedVars).
local function createProfilePickerRow(variable, name, tooltip, valuesGenerator)
- local selected = getPreferredProfileSelection(valuesGenerator)
+ local selected
local function ensureSelection()
- if not selected or selected == "" then
+ if selected == nil or selected == "" then
selected = getPreferredProfileSelection(valuesGenerator)
end
+
+ return selected
end
local function values()
@@ -87,8 +89,6 @@ local function createProfilePickerRow(variable, name, tooltip, valuesGenerator)
return map
end
- ensureSelection()
-
return {
type = "dropdown",
key = variable,
@@ -98,24 +98,27 @@ local function createProfilePickerRow(variable, name, tooltip, valuesGenerator)
scrollHeight = 240,
values = values,
get = function()
- ensureSelection()
- return selected
+ return ensureSelection()
end,
set = function(value)
selected = value
end,
}, function()
- ensureSelection()
- return selected
+ return ensureSelection()
end, function()
- selected = getPreferredProfileSelection(valuesGenerator)
+ selected = nil
end
end
local function otherProfilesGenerator()
local container = Settings.CreateControlTextContainer()
- local current = ns.Addon.db:GetCurrentProfile()
- for _, name in ipairs(ns.Addon.db:GetProfiles()) do
+ local db = ns.Addon and ns.Addon.db
+ if not db then
+ return container:GetData()
+ end
+
+ local current = db:GetCurrentProfile()
+ for _, name in ipairs(db:GetProfiles()) do
if name ~= current then
container:Add(name, name)
end
From 7298a976e0eb5df7971d5ea0c9424a0ef9db8313 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 18 Apr 2026 23:04:56 +1000
Subject: [PATCH 25/53] WIP external defensives
---
.../externalbars-runtime-fade-ownership.md | 3 +
ARCHITECTURE.md | 116 ++-
BarStyle.lua | 276 +++++++
Constants.lua | 23 +-
Defaults.lua | 44 +-
EnhancedCooldownManager.toc | 4 +
Libs/LibSettingsBuilder/Controls/Rows.lua | 23 +-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 42 +
Locales/en.lua | 9 +
Modules/BuffBars.lua | 243 +-----
Modules/ExternalBars.lua | 644 +++++++++++++++
Modules/ExtraIcons.lua | 21 +-
Runtime.lua | 8 +
SpellColors.lua | 242 ++++--
Tests/BarMixin_spec.lua | 12 +
Tests/ECM_Runtime_spec.lua | 9 +
Tests/Modules/BuffBars_spec.lua | 127 ++-
Tests/Modules/ExternalBars_spec.lua | 768 ++++++++++++++++++
Tests/Modules/ExtraIcons_spec.lua | 18 +-
Tests/SpellColors_spec.lua | 402 ++++++---
Tests/UI/BuffBarsOptions_spec.lua | 411 +++++++++-
Tests/UI/BuffBarsSettingsOptions_spec.lua | 1 +
Tests/UI/ExternalBarsOptions_spec.lua | 79 ++
Tests/UI/OptionsSections_spec.lua | 7 +
Tests/UI/Options_spec.lua | 6 +
UI/BuffBarsOptions.lua | 417 +---------
UI/ExternalBarsOptions.lua | 107 +++
UI/LayoutOptions.lua | 2 +
UI/Options.lua | 39 +-
UI/SpellColorsPage.lua | 628 ++++++++++++++
30 files changed, 3819 insertions(+), 912 deletions(-)
create mode 100644 .serena/memories/externalbars-runtime-fade-ownership.md
create mode 100644 BarStyle.lua
create mode 100644 Modules/ExternalBars.lua
create mode 100644 Tests/Modules/ExternalBars_spec.lua
create mode 100644 Tests/UI/ExternalBarsOptions_spec.lua
create mode 100644 UI/ExternalBarsOptions.lua
create mode 100644 UI/SpellColorsPage.lua
diff --git a/.serena/memories/externalbars-runtime-fade-ownership.md b/.serena/memories/externalbars-runtime-fade-ownership.md
new file mode 100644
index 00000000..cd98beca
--- /dev/null
+++ b/.serena/memories/externalbars-runtime-fade-ownership.md
@@ -0,0 +1,3 @@
+- `Runtime` owns the current fade alpha via `Runtime.GetDesiredAlpha()`.
+- `Modules/ExternalBars.lua` must restore `ExternalDefensivesFrame` using that runtime alpha instead of hardcoding `1`, otherwise aura/layout refreshes can pop the Blizzard external viewer back to full opacity.
+- When restoring original icons, keep mouse enabled only when the restored alpha is greater than 0.
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index b913e168..1919582f 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,7 +1,7 @@
# ECM Architecture
EnhancedCooldownManager is an event-driven WoW addon built on AceAddon-3.0 / AceDB-3.0.
-`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, and iterates modules. Each module (PowerBar, ResourceBar, RuneBar, BuffBars, ExtraIcons) inherits from `BarMixin` and implements its own `UpdateLayout()`.
+`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, lays out `ExtraIcons` first when it widens the main viewer, and then iterates the chained bar modules. `PowerBar`, `ResourceBar`, and `RuneBar` use `BarMixin.AddBarMixin()`. `BuffBars`, `ExternalBars`, and `ExtraIcons` use `BarMixin.AddFrameMixin()` and manage their own child content.
## Initialization Chain
@@ -27,7 +27,8 @@ flowchart TD
RB_INIT["ResourceBar:OnInitialize()
BarMixin.AddBarMixin(self)"]
RUNE_INIT["RuneBar:OnInitialize()
BarMixin.AddBarMixin(self)"]
BB_INIT["BuffBars:OnInitialize()
BarMixin.AddFrameMixin(self)"]
- II_INIT["ExtraIcons:OnInitialize()"]
+ EB_INIT["ExternalBars:OnInitialize()
BarMixin.AddFrameMixin(self)"]
+ II_INIT["ExtraIcons:OnInitialize()
BarMixin.AddFrameMixin(self)"]
end
subgraph ENABLE["Phase 4 · OnEnable → Runtime.Enable"]
@@ -55,8 +56,10 @@ flowchart TD
REG_FRAME["Runtime.RegisterFrame(self)
→ _modules[name] = self"]
REG_EVENTS["Register module-specific events
(UNIT_POWER_UPDATE, etc.)"]
HOOK_BB["BuffBars: C_Timer.After(0.1)
→ HookViewer() + RequestLayout"]
+ HOOK_EB["ExternalBars: C_Timer.After(0.1)
→ HookViewer() + UpdateAuras + RequestLayout"]
ENSURE --> REG_FRAME --> REG_EVENTS
REG_EVENTS -.->|BuffBars only| HOOK_BB
+ REG_FRAME -.->|ExternalBars only| HOOK_EB
end
subgraph FIRST["Phase 6 · First Layout"]
@@ -99,11 +102,12 @@ flowchart TD
BB_ZONE["ZONE_CHANGED_* / PLAYER_ENTERING_WORLD
→ BuffBars:OnZoneChanged"]
end
- subgraph HOOKS["Frame Hooks (BuffBars)"]
+ subgraph HOOKS["Frame Hooks (BuffBars / ExternalBars)"]
BB_SETPT["child:SetPoint hook
→ restore anchors + restyle"]
BB_SHOW["child:OnShow hook
→ restyle"]
BB_HIDE["child:OnHide hook"]
BB_VIEWER["viewer:OnShow / OnSizeChanged"]
+ EB_VIEWER["ExternalDefensivesFrame:UpdateAuras / OnShow / OnHide
→ ExternalBars sync + RequestLayout"]
end
subgraph RUNTIME["Runtime.lua — Event Dispatch"]
@@ -143,10 +147,12 @@ flowchart TD
subgraph UPDATE_ALL["updateAllLayouts(reason)"]
INV_DET["invalidateDetachedAnchorMetrics()"]
UPD_DET["updateDetachedAnchorLayout()"]
- CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ExtraIcons"]
+ EXTRA_FIRST["ExtraIcons:UpdateLayout(reason)
first, so the main viewer width is final"]
+ CHAIN_LOOP["For each module in CHAIN_ORDER:
PowerBar → ResourceBar → RuneBar
→ BuffBars → ExternalBars"]
+ OTHER_LOOP["Remaining non-chain modules (if any)"]
MOD_UPD["module:UpdateLayout(reason)"]
- INV_DET --> UPD_DET --> CHAIN_LOOP --> MOD_UPD
+ INV_DET --> UPD_DET --> EXTRA_FIRST --> CHAIN_LOOP --> OTHER_LOOP --> MOD_UPD
end
%% Event Sources → Runtime
@@ -159,7 +165,7 @@ flowchart TD
PB_PWR & RB_AURA & BB_ZONE --> REQ_LAY
%% Hooks → RequestLayout
- BB_SETPT & BB_SHOW & BB_HIDE & BB_VIEWER --> REQ_LAY
+ BB_SETPT & BB_SHOW & BB_HIDE & BB_VIEWER & EB_VIEWER --> REQ_LAY
%% All paths → executeLayout
REQ_LAY --> EXEC_LAY
@@ -183,7 +189,7 @@ flowchart TD
### Profile Change
-When a user switches, copies, or resets a profile, AceDB fires a callback → `ECM:OnProfileChangedHandler()` → re-runs migration → `Runtime.Enable()` re-enables/disables modules per new config → schedules a full layout with reason `"ProfileChanged"`. BuffBars clears its SpellColors cache on this reason.
+When a user switches, copies, or resets a profile, AceDB fires a callback → `ECM:OnProfileChangedHandler()` → re-runs migration → `Runtime.Enable()` re-enables/disables modules per new config → schedules a full layout with reason `"ProfileChanged"`. BuffBars and ExternalBars clear their scoped SpellColors discovered-key caches on this reason.
### Edit Mode
@@ -202,6 +208,8 @@ Options pages now use LibSettingsBuilder as a single declarative registration tr
- `root:Register(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes.
+`UI/SpellColorsPage.lua` now owns the shared Spell Colors subcategory. `BuffBarsOptions` registers the page once, and both `BuffBars` and `ExternalBars` register scoped sections into it, so the two modules share one editor without sharing saved color pools.
+
LibSettingsBuilder v2 Phase 1 also freezes the intended replacement surface without switching ECM over to it yet:
- target factory: `LSB.New(config)`
@@ -224,6 +232,17 @@ Pages still use the same canonical row types:
A 0.5s `C_Timer.NewTicker` handles deferred Blizzard frame hooking (stops retrying once setup completes), enforces hidden/alpha state against Blizzard re-shows, and syncs module alpha.
+### External Bars / `ExternalDefensivesFrame`
+
+`ExternalBars` is hook-driven rather than event-driven. It mirrors Blizzard's `ExternalDefensivesFrame` instead of scanning auras itself:
+
+1. `OnEnable()` defers `HookViewer()` by `C_Timer.After(0.1)` so the Blizzard frame exists before hooks are attached.
+2. `HookViewer()` post-hooks `ExternalDefensivesFrame:UpdateAuras()` and listens to the frame's `OnShow` / `OnHide` transitions.
+3. `OnExternalAurasUpdated()` copies `viewer.auraInfo[]` into `_auraStates[]`, enriches accessible spell metadata through `C_UnitAuras.GetAuraDataByAuraInstanceID()`, and requests a layout pass.
+4. `UpdateLayout()` maps `_auraStates[]` to pooled child bars, styles each row through `BarStyle.StyleChildBar(...)`, and routes spell-color discovery / lookup through the `ns.SpellColors.Get("externalBars")` store.
+5. Bar fill is rendered by a `Cooldown` overlay via `SetCooldownDuration(duration, timeMod)`. Lua only formats duration text when expiration data is safe to inspect directly.
+6. `hideOriginalIcons` uses `ExternalDefensivesFrame:SetAlpha(0)` plus `EnableMouse(false)` rather than `Hide()`, so Blizzard keeps driving `UpdateAuras()`.
+
```mermaid
flowchart TD
subgraph PROFILE["Profile Change Flow"]
@@ -233,9 +252,9 @@ flowchart TD
MIG["Migration.Run(new profile)"]
RT_EN2["Runtime.Enable(addon)
→ Re-enable/disable modules per new config"]
SCHED_PC["ScheduleLayoutUpdate(0, 'ProfileChanged')"]
- BB_CLEAR["BuffBars:UpdateLayout('ProfileChanged')
→ SpellColors.ClearDiscoveredKeys()"]
+ SPELL_CLEAR["BuffBars / ExternalBars:UpdateLayout('ProfileChanged')
→ SpellColors.Get(scope):ClearDiscoveredKeys()"]
- USER_SWITCH --> ACE_CB --> PROF_HANDLER --> MIG --> RT_EN2 --> SCHED_PC --> BB_CLEAR
+ USER_SWITCH --> ACE_CB --> PROF_HANDLER --> MIG --> RT_EN2 --> SCHED_PC --> SPELL_CLEAR
end
subgraph EDITMODE["Edit Mode Flow"]
@@ -291,6 +310,8 @@ flowchart TD
Runtime registers the shared layout events; modules register their own data-driven events in `OnEnable`. Events with multiple registrants are intentional — Runtime handles visibility/positioning while the module handles its own data refresh.
+`ExternalBars` is intentionally absent from this table: it mirrors `ExternalDefensivesFrame` through hooks rather than registering its own aura event stream.
+
| Event | Registrant(s) | Purpose |
|-------|---------------|---------|
| CVAR_UPDATE | Runtime | Schedules layout when `cooldownViewerEnabled` changes |
@@ -344,7 +365,7 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
| Method | Description |
|--------|-------------|
-| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ExtraIcons) |
+| `AddFrameMixin(target, name)` | Apply frame-only mixin (used by BuffBars, ExternalBars, ExtraIcons) |
| `AddBarMixin(module, name)` | Apply bar mixin: frame + StatusBar + ticks (used by PowerBar, ResourceBar, RuneBar) |
**FrameProto (mixed into every module):**
@@ -376,6 +397,31 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
| `LayoutResourceTicks(maxResources, color?, tickWidth?, poolKey?)` | Position ticks as resource dividers |
| `LayoutValueTicks(statusBar, ticks, maxValue, defaultColor, defaultWidth, poolKey?)` | Position ticks at specific values |
+`BarStyle` (`BarStyle.lua`) is a stateless namespace of shared child-bar styling helpers used by both `BuffBars` and `ExternalBars`. It is not a mixin — callers invoke the helpers directly (e.g. `BarStyle.StyleChildBar(...)`) so both modules render through the same icon, background, anchor, and spell-color paths.
+
+**Shared child-bar styling helpers:**
+
+| Method | Description |
+|--------|-------------|
+| `ApplySquareIconStyle(iconFrame, iconTexture, iconOverlay, debuffBorder)` | Remove Blizzard round-mask / overlay treatment once and keep icons square |
+| `StyleBarHeight(frame, bar, iconFrame, config, globalConfig)` | Apply shared row height to the container, status bar, and icon |
+| `StyleBarBackground(frame, barBG, config, globalConfig)` | Reparent and restyle the shared bar background texture |
+| `StyleBarColor(module, frame, bar, globalConfig, spellColors?, retryCount?)` | Resolve spell colors through a per-scope store with secret-value retry handling |
+| `StyleBarIcon(frame, iconFrame, config)` | Show, hide, and align the optional icon region |
+| `StyleBarAnchors(frame, bar, iconFrame, config)` | Apply the shared text / icon anchor layout |
+| `StyleChildBar(module, frame, config, globalConfig, spellColors?)` | Run the complete shared BuffBars / ExternalBars child-bar styling pass |
+
+### ExternalBars (`Modules/ExternalBars.lua`)
+
+Renders Blizzard external defensive auras as ECM-owned bar rows. `ExternalBars` inherits `FrameProto`, owns a pooled set of child bars, and shares the BuffBars row styling helpers from `BarStyle`.
+
+- **Authoritative source:** `ExternalDefensivesFrame.auraInfo[]` populated by Blizzard `UpdateAuras()`.
+- **Viewer hook:** `HookViewer()` post-hooks `UpdateAuras()` and `OnShow` / `OnHide`.
+- **Internal state:** `OnExternalAurasUpdated()` copies current aura entries into `_auraStates[]` keyed by Blizzard's aura-array index, not by `auraInstanceID`.
+- **Rendering:** `UpdateLayout()` sizes the container, configures pooled child bars, and uses a `Cooldown` overlay per row for the draining fill.
+- **Color scope:** spell-color lookup and discovery run through `ns.SpellColors.Get("externalBars")`, so the shared Spell Colors page can manage it independently from BuffBars while the runtime avoids passing scope strings through every call.
+- **Original icon suppression:** `hideOriginalIcons` drives `SetAlpha(0)` / `EnableMouse(false)` on `ExternalDefensivesFrame`; it never calls `Hide()`.
+
### ExtraIcons (`Modules/ExtraIcons.lua`)
Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Uses a dual-viewer architecture with a stack-aware resolver.
@@ -432,7 +478,7 @@ Lazy setters avoid redundant frame API calls — they compare the new value agai
### SpellColors (`ns.SpellColors`)
-Multi-tier key system for per-spell color customization on buff bars. Keys match across spell name, spell ID, cooldown ID, and texture file ID.
+Shared multi-tier key system for per-spell color customization on BuffBars and ExternalBars. Keys match across spell name, spell ID, cooldown ID, and texture file ID. Scope-specific state now lives on `ECM_SpellColorStore` instances created by `ns.SpellColors.New(scope, accessor?)` or retrieved from the shared registry via `ns.SpellColors.Get(scope)`, so BuffBars and ExternalBars share lookup logic while keeping separate defaults, saved colors, and discovered-key caches.
| Method | Description |
|--------|-------------|
@@ -440,21 +486,39 @@ Multi-tier key system for per-spell color customization on buff bars. Keys match
| `NormalizeKey(key)` | Normalize key payload into opaque key |
| `KeysMatch(left, right)` | Check if two keys identify the same spell |
| `MergeKeys(base, other)` | Merge identifiers from matching keys |
-| `GetColorByKey(key)` | Get custom color for spell |
-| `GetColorForBar(frame)` | Get custom color for a buff bar frame |
-| `SetColorByKey(key, color)` | Set custom color for spell |
-| `ResetColorByKey(key)` | Remove custom color entry |
-| `GetAllColorEntries()` | Return deduplicated color entries for current class/spec |
-| `GetDefaultColor()` | Return default color for class/spec |
-| `SetDefaultColor(color)` | Set default color for class/spec |
-| `ReconcileAllKeys(keys)` | Batch-reconcile keys (propagate most-recent across tiers) |
-| `RemoveEntriesByKeys(keys)` | Remove matching persisted and discovered spell-color keys |
-| `DiscoverBar(frame)` | Register a discovered bar key |
-| `ClearDiscoveredKeys()` | Clear discovered key cache |
-| `ClearCurrentSpecColors()` | Clear all colors for current class/spec |
-| `SetConfigAccessor(accessor)` | Inject config accessor (decouples from AceDB) |
-
-The spell-color settings canvas (`UI/BuffBarsOptions.lua`) merges persisted and discovered keys into one list, enables `Reconcile` and `Remove Stale` only when a row is still missing one or more identifiers, and lets `Remove Stale` confirmed-delete incomplete entries from both the current-spec stores and the runtime discovered-key cache while echoing each removal to chat.
+| `New(scope, accessor?)` | Create an isolated spell-color store (primarily for tests) |
+| `Get(scope?)` | Return the shared spell-color store for a scope |
+
+Store methods (`SpellColors.Get(scope):...`):
+
+| Method | Description |
+|--------|-------------|
+| `GetColorByKey(key)` | Get a custom color for a spell within that store's scope |
+| `GetColorForBar(frame)` | Get a custom color for a BuffBars / ExternalBars row |
+| `SetColorByKey(key, color)` | Set a custom color for a spell within that store's scope |
+| `ResetColorByKey(key)` | Remove a custom color entry |
+| `GetAllColorEntries()` | Return deduplicated color entries for the current class/spec in that store's scope |
+| `GetDefaultColor()` | Return the default color for the current class/spec in that store's scope |
+| `SetDefaultColor(color)` | Set the default color for the current class/spec in that store's scope |
+| `ReconcileAllKeys(keys)` | Batch-reconcile keys within that store's scope (propagate most-recent across tiers) |
+| `RemoveEntriesByKeys(keys)` | Remove matching persisted and discovered spell-color keys within that store's scope |
+| `DiscoverBar(frame)` | Register a discovered bar key within that store's scope |
+| `ClearDiscoveredKeys()` | Clear the discovered-key cache for that store's scope |
+| `ClearCurrentSpecColors()` | Clear all colors for the current class/spec in that store's scope |
+| `_SetConfigAccessor(accessor)` | Private test-only override for swapping the config source after construction |
+
+The spell-color settings page (`UI/SpellColorsPage.lua`) renders one shared multi-section canvas. Each section merges persisted and discovered keys for its own scope, enables `Reconcile` and `Remove Stale` only when a row is still missing one or more identifiers, and lets `Remove Stale` confirmed-delete incomplete entries from both the current-spec stores and the runtime discovered-key cache while echoing each removal to chat.
+
+### SpellColorsPage (`UI/SpellColorsPage.lua`)
+
+Shared settings-page builder for spell colors. BuffBars and ExternalBars both register sections into the same page rather than owning duplicate subcategories.
+
+| Method | Description |
+|--------|-------------|
+| `RegisterSection(section)` | Register or update a spell-color section (`key`, `label`, `scope`, `isDisabledDelegate`, `ownerModuleName`) |
+| `CreateSectionDisabledDelegate(configPath, ownerModuleName)` | Create the disabled predicate used by a section |
+| `CreatePage(subcatName)` | Return the shared multi-section page spec used by options registration |
+| `SetRegisteredPage(page)` | Cache the live page handle so runtime changes can refresh it |
### ClassUtil (`ns.ClassUtil`)
diff --git a/BarStyle.lua b/BarStyle.lua
new file mode 100644
index 00000000..ea47287f
--- /dev/null
+++ b/BarStyle.lua
@@ -0,0 +1,276 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local C = ns.Constants
+local FrameUtil = ns.FrameUtil
+
+--------------------------------------------------------------------------------
+-- Shared child-bar styling helpers (BuffBars / ExternalBars)
+--
+-- Stateless functions that operate on already-constructed bar widgets.
+-- Not a mixin — callers invoke these directly as `BarStyle.StyleChildBar(...)`.
+--------------------------------------------------------------------------------
+
+--- Strips circular masks and hides overlay/border to produce a square icon.
+--- The heavy cleanup (mask removal, pcalls, region iteration) is cached on the
+--- frame via `__ecmSquareStyled` so it only runs once per icon frame.
+---@param iconFrame Frame|nil
+---@param iconTexture Texture|nil
+---@param iconOverlay Texture|nil
+---@param debuffBorder Texture|nil
+local function applySquareIconStyle(iconFrame, iconTexture, iconOverlay, debuffBorder)
+ if not iconFrame or iconFrame.__ecmSquareStyled or not iconTexture then
+ return
+ end
+
+ iconTexture:SetTexCoord(0, 1, 0, 1)
+
+ -- Remove circular masks from the icon texture
+ if iconTexture.GetNumMaskTextures and iconTexture.RemoveMaskTexture and iconTexture.GetMaskTexture then
+ for i = (iconTexture:GetNumMaskTextures() or 0), 1, -1 do
+ local mask = iconTexture:GetMaskTexture(i)
+ if mask then
+ iconTexture:RemoveMaskTexture(mask)
+ if mask.Hide then mask:Hide() end
+ end
+ end
+ elseif iconTexture.SetMask then
+ pcall(iconTexture.SetMask, iconTexture, nil)
+ end
+
+ -- Remove mask regions from the icon frame
+ if iconFrame.GetRegions and iconTexture.RemoveMaskTexture then
+ for _, region in ipairs({ iconFrame:GetRegions() }) do
+ if region and region.IsObjectType and region:IsObjectType("MaskTexture") then
+ pcall(iconTexture.RemoveMaskTexture, iconTexture, region)
+ if region.Hide then region:Hide() end
+ end
+ end
+ end
+
+ if iconOverlay then iconOverlay:Hide() end
+ if debuffBorder then debuffBorder:Hide() end
+
+ iconFrame.__ecmSquareStyled = true
+end
+
+---@param frame Frame
+---@param bar StatusBar
+---@param iconFrame Frame|nil
+---@param config table|nil
+---@param globalConfig table|nil
+local function styleBarHeight(frame, bar, iconFrame, config, globalConfig)
+ local height = (config and config.height) or (globalConfig and globalConfig.barHeight) or 15
+ if height <= 0 then
+ return
+ end
+ FrameUtil.LazySetHeight(frame, height)
+ FrameUtil.LazySetHeight(bar, height)
+ if iconFrame then
+ FrameUtil.LazySetHeight(iconFrame, height)
+ FrameUtil.LazySetWidth(iconFrame, height)
+ end
+end
+
+---@param frame Frame
+---@param barBG Texture|nil
+---@param config table|nil
+---@param globalConfig table|nil
+local function styleBarBackground(frame, barBG, config, globalConfig)
+ if not barBG then
+ return
+ end
+
+ -- One-time setup: reparent BarBG to the outer frame and hook SetPoint
+ -- so Blizzard cannot override our anchors. SetAllPoints does not fire
+ -- SetPoint hooks, so no re-entrancy guard is needed.
+ if not barBG.__ecmBGHooked then
+ barBG.__ecmBGHooked = true
+ barBG:SetParent(frame)
+ hooksecurefunc(barBG, "SetPoint", function()
+ barBG:ClearAllPoints()
+ barBG:SetAllPoints(frame)
+ end)
+ end
+
+ local bgColor = (config and config.bgColor)
+ or (globalConfig and globalConfig.barBgColor)
+ or ns.Constants.COLOR_BLACK
+ barBG:SetTexture(ns.Constants.FALLBACK_TEXTURE)
+ barBG:SetVertexColor(bgColor.r, bgColor.g, bgColor.b, bgColor.a)
+ barBG:ClearAllPoints()
+ barBG:SetAllPoints(frame)
+ barBG:SetDrawLayer("BACKGROUND", 0)
+end
+
+--- Resolves the spell color for a bar, handling secret values with retry.
+--- Returns true if the module's _editLocked flag was set by this call.
+---@param module table
+---@param frame ECM_BuffBarMixin|Frame
+---@param bar StatusBar
+---@param globalConfig table|nil
+---@param spellColors ECM_SpellColorStore|nil
+---@param retryCount number|nil
+---@return boolean|nil
+local function styleBarColor(module, frame, bar, globalConfig, spellColors, retryCount)
+ local resolvedSpellColors = spellColors or ns.SpellColors.Get(C.SCOPE_BUFFBARS)
+ local currentRetryCount = retryCount or 0
+ local textureName = globalConfig and globalConfig.texture
+ FrameUtil.LazySetStatusBarTexture(bar, FrameUtil.GetTexture(textureName))
+
+ local barColor = resolvedSpellColors:GetColorForBar(frame)
+ local spellName = bar.Name and bar.Name.GetText and bar.Name:GetText()
+ local spellID = frame.cooldownInfo and frame.cooldownInfo.spellID
+ local cooldownID = frame.cooldownID
+ local textureFileID = FrameUtil.GetIconTextureFileID(frame)
+
+ -- When in a raid instance, and after exiting combat, all identifying
+ -- values may remain secret. Lock editing only when every key is unusable.
+ -- With four tiers (name, spellID, cooldownID, texture) the colour lookup
+ -- is much more resilient to partial secrecy.
+ local allSecret = issecretvalue(spellName)
+ and issecretvalue(spellID)
+ and issecretvalue(cooldownID)
+ and issecretvalue(textureFileID)
+ module._editLocked = module._editLocked or allSecret
+
+ if allSecret and not InCombatLockdown() then
+ if currentRetryCount < 3 then
+ if frame._ecmColorRetryTimer then
+ frame._ecmColorRetryTimer:Cancel()
+ end
+ frame._ecmColorRetryTimer = C_Timer.NewTimer(1, function()
+ frame._ecmColorRetryTimer = nil
+ styleBarColor(module, frame, bar, globalConfig, resolvedSpellColors, currentRetryCount + 1)
+ end)
+ -- Don't apply any colour while retries are pending — preserve
+ -- the bar's existing colour rather than clobbering it with the
+ -- default while we wait for secrets to clear.
+ return nil
+ elseif ns.IsDebugEnabled() and not module._warned then
+ ns.Log(ns.Constants.BUFFBARS, "All identifying keys are secret outside of combat.")
+ module._warned = true
+ end
+ end
+
+ if frame._ecmColorRetryTimer then
+ frame._ecmColorRetryTimer:Cancel()
+ frame._ecmColorRetryTimer = nil
+ end
+
+ if barColor == nil and not allSecret then
+ barColor = resolvedSpellColors:GetDefaultColor()
+ end
+ if barColor then
+ FrameUtil.LazySetStatusBarColor(bar, barColor.r, barColor.g, barColor.b, 1.0)
+ end
+
+ return module._editLocked
+end
+
+---@param frame Frame
+---@param iconFrame Frame|nil
+---@param config table|nil
+local function styleBarIcon(frame, iconFrame, config)
+ local showIcon = config and config.showIcon ~= false
+
+ if iconFrame then
+ FrameUtil.LazySetAnchors(iconFrame, {
+ { "TOPLEFT", frame, "TOPLEFT", 0, 0 },
+ })
+ local iconTexture = FrameUtil.GetIconTexture(frame)
+ local iconOverlay = FrameUtil.GetIconOverlay(frame)
+ applySquareIconStyle(iconFrame, iconTexture, iconOverlay, frame.DebuffBorder)
+ iconFrame:SetShown(showIcon)
+ if iconTexture then
+ iconTexture:SetShown(showIcon)
+ end
+ end
+
+ if frame.DebuffBorder then
+ FrameUtil.LazySetAlpha(frame.DebuffBorder, 0)
+ frame.DebuffBorder:Hide()
+ end
+ if iconFrame and iconFrame.Applications then
+ FrameUtil.LazySetAlpha(iconFrame.Applications, showIcon and 1 or 0)
+ end
+end
+
+---@param frame Frame
+---@param bar StatusBar
+---@param iconFrame Frame|nil
+---@param config table|nil
+local function styleBarAnchors(frame, bar, iconFrame, config)
+ local showSpellName = config and config.showSpellName ~= false
+ local showDuration = config and config.showDuration ~= false
+ if bar.Name then
+ bar.Name:SetShown(showSpellName)
+ end
+ if bar.Duration then
+ bar.Duration:SetShown(showDuration)
+ end
+
+ local iconVisible = iconFrame and iconFrame:IsShown()
+ local barLeftAnchor = iconVisible and iconFrame or frame
+ local barLeftPoint = iconVisible and "TOPRIGHT" or "TOPLEFT"
+ FrameUtil.LazySetAnchors(bar, {
+ { "TOPLEFT", barLeftAnchor, barLeftPoint, 0, 0 },
+ { "TOPRIGHT", frame, "TOPRIGHT", 0, 0 },
+ })
+
+ FrameUtil.LazySetAnchors(bar.Name, {
+ { "LEFT", bar, "LEFT", ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
+ { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
+ })
+
+ if bar.Duration then
+ FrameUtil.LazySetAnchors(bar.Duration, {
+ { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
+ })
+ end
+end
+
+--- Applies all sizing, styling, visibility, and anchoring to a single child bar.
+--- Lazy setters ensure no-ops when values haven't changed.
+---@param module table
+---@param frame ECM_BuffBarMixin|Frame
+---@param config table|nil
+---@param globalConfig table|nil
+---@param spellColors ECM_SpellColorStore|nil
+local function styleChildBar(module, frame, config, globalConfig, spellColors)
+ if not (frame and frame.__ecmHooked) then
+ ns.DebugAssert(false, "Attempted to style a child frame that wasn't hooked.")
+ return
+ end
+
+ local bar = frame.Bar
+ local iconFrame = frame.Icon
+
+ styleBarHeight(frame, bar, iconFrame, config, globalConfig)
+
+ bar.Pip:Hide()
+ bar.Pip:SetTexture(nil)
+
+ styleBarBackground(frame, FrameUtil.GetBarBackground(bar), config, globalConfig)
+ styleBarColor(module, frame, bar, globalConfig, spellColors, 0)
+
+ FrameUtil.ApplyFont(bar.Name, globalConfig, config)
+ FrameUtil.ApplyFont(bar.Duration, globalConfig, config)
+
+ styleBarIcon(frame, iconFrame, config)
+ styleBarAnchors(frame, bar, iconFrame, config)
+end
+
+local BarStyle = {
+ ApplySquareIconStyle = applySquareIconStyle,
+ StyleBarHeight = styleBarHeight,
+ StyleBarBackground = styleBarBackground,
+ StyleBarColor = styleBarColor,
+ StyleBarIcon = styleBarIcon,
+ StyleBarAnchors = styleBarAnchors,
+ StyleChildBar = styleChildBar,
+}
+
+ns.BarStyle = BarStyle
diff --git a/Constants.lua b/Constants.lua
index ec2ae8de..5c635087 100644
--- a/Constants.lua
+++ b/Constants.lua
@@ -14,6 +14,7 @@ local constants = {
-- Module identifiers
BUFFBARS = "BuffBars",
+ EXTERNALBARS = "ExternalBars",
EXTRAICONS = "ExtraIcons",
POWERBAR = "PowerBar",
RESOURCEBAR = "ResourceBar",
@@ -27,6 +28,8 @@ local constants = {
EDIT_MODE_DEFAULT_POINT = "CENTER",
GROW_DIRECTION_DOWN = "down",
GROW_DIRECTION_UP = "up",
+ SCOPE_BUFFBARS = "buffBars",
+ SCOPE_EXTERNALBARS = "externalBars",
-- Shared visuals and defaults
COLOR_BLACK = { r = 0, g = 0, b = 0, a = 1 },
@@ -275,7 +278,8 @@ local moduleConfigKeys = {
[constants.POWERBAR] = "powerBar",
[constants.RESOURCEBAR] = "resourceBar",
[constants.RUNEBAR] = "runeBar",
- [constants.BUFFBARS] = "buffBars",
+ [constants.BUFFBARS] = constants.SCOPE_BUFFBARS,
+ [constants.EXTERNALBARS] = constants.SCOPE_EXTERNALBARS,
[constants.EXTRAICONS] = "extraIcons",
}
@@ -285,9 +289,22 @@ function constants.ConfigKeyForModule(name)
return moduleConfigKeys[name] or (name:sub(1, 1):lower() .. name:sub(2))
end
-local chainOrder = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS }
+local chainOrder = {
+ constants.POWERBAR,
+ constants.RESOURCEBAR,
+ constants.RUNEBAR,
+ constants.BUFFBARS,
+ constants.EXTERNALBARS,
+}
constants.CHAIN_ORDER = chainOrder
-constants.MODULE_ORDER = { constants.POWERBAR, constants.RESOURCEBAR, constants.RUNEBAR, constants.BUFFBARS, constants.EXTRAICONS }
+constants.MODULE_ORDER = {
+ constants.POWERBAR,
+ constants.RESOURCEBAR,
+ constants.RUNEBAR,
+ constants.BUFFBARS,
+ constants.EXTERNALBARS,
+ constants.EXTRAICONS,
+}
constants.MODULE_CONFIG_KEYS = moduleConfigKeys
constants.BLIZZARD_FRAMES = BLIZZARD_FRAMES
constants.BUILTIN_STACKS = BUILTIN_STACKS
diff --git a/Defaults.lua b/Defaults.lua
index bed27b62..58845986 100644
--- a/Defaults.lua
+++ b/Defaults.lua
@@ -15,6 +15,10 @@ local _, ns = ...
---@field x number X offset from anchor.
---@field y number Y offset from anchor.
+---@alias ns.Constants.ANCHORMODE_CHAIN "chain"
+---@alias ns.Constants.ANCHORMODE_DETACHED "detached"
+---@alias ns.Constants.ANCHORMODE_FREE "free"
+
---@class ECM_BarConfigBase Shared bar layout configuration.
---@field enabled boolean Whether the bar is enabled.
---@field editModePositions table|nil Per-layout positions saved via Edit Mode.
@@ -85,7 +89,7 @@ local _, ns = ...
---@field byCooldownID table>> Per-cooldownID colors by class/spec/cooldownID.
---@field byTexture table>> Per-texture colors by class/spec/textureId.
---@field cache table>> Cached bar metadata by class/spec/index.
----@field defaultColor ECM_Color Default color for buff bars.
+---@field defaultColor ECM_Color Default color when no per-spell override applies.
---@class ECM_BuffBarsConfig Buff bars configuration.
---@field enabled boolean Whether buff bars are enabled.
@@ -99,6 +103,22 @@ local _, ns = ...
---@field fontSize number|nil Font size override for aura bar text.
---@field colors ECM_SpellColorsConfig Per-spell color settings.
+---@class ECM_ExternalBarsConfig External cooldown bars configuration.
+---@field enabled boolean Whether external cooldown bars are enabled.
+---@field hideOriginalIcons boolean Whether Blizzard's original external cooldown icons are hidden.
+---@field anchorMode ns.Constants.ANCHORMODE_CHAIN|ns.Constants.ANCHORMODE_DETACHED|ns.Constants.ANCHORMODE_FREE|nil Anchor behavior for external cooldown bars.
+---@field editModePositions table|nil Per-layout positions saved via Edit Mode.
+---@field width number|nil Bar width override.
+---@field height number|nil Bar height override.
+---@field verticalSpacing number|nil Vertical gap between bars (pixels).
+---@field showIcon boolean|nil Whether to show external cooldown icons.
+---@field showSpellName boolean|nil Whether to show spell names.
+---@field showDuration boolean|nil Whether to show durations.
+---@field overrideFont boolean|nil Whether external cooldown bars override global font settings.
+---@field font string|nil Font face override for bar text.
+---@field fontSize number|nil Font size override for bar text.
+---@field colors ECM_SpellColorsConfig Per-spell color settings.
+
---@class ECM_ExtraIconEntry
---@field stackKey string|nil Built-in stack key resolved via `BUILTIN_STACKS`.
---@field kind string|nil Entry kind for custom or racial rows.
@@ -134,6 +154,7 @@ local _, ns = ...
---@field resourceBar ECM_ResourceBarConfig Resource bar settings.
---@field runeBar ECM_RuneBarConfig Rune bar settings.
---@field buffBars ECM_BuffBarsConfig Buff bars configuration.
+---@field externalBars ECM_ExternalBarsConfig External cooldown bars configuration.
---@field extraIcons ECM_ExtraIconsConfig Extra icons configuration.
local C = ns.Constants
@@ -276,6 +297,27 @@ local defaults = {
defaultColor = { r = 228 / 255, g = 233 / 255, b = 235 / 255, a = 1 },
},
},
+ externalBars = {
+ enabled = false,
+ hideOriginalIcons = false,
+ anchorMode = C.ANCHORMODE_CHAIN,
+ editModePositions = {},
+ width = C.DEFAULT_BAR_WIDTH,
+ height = 0,
+ verticalSpacing = 0,
+ showIcon = true,
+ showSpellName = true,
+ showDuration = true,
+ overrideFont = false,
+ colors = {
+ byName = {},
+ bySpellID = {},
+ byCooldownID = {},
+ byTexture = {},
+ cache = {},
+ defaultColor = { r = 0.40, g = 0.78, b = 0.95, a = 1 },
+ },
+ },
extraIcons = {
enabled = true,
viewers = {
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 34b9ec8b..9e8566ba 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -35,12 +35,14 @@ Migration.lua
ImportExport.lua
BarMixin.lua
+BarStyle.lua
ECM.lua
Runtime.lua
UI\Dialogs.lua
Modules\BuffBars.lua
+Modules\ExternalBars.lua
Modules\PowerBar.lua
Modules\ResourceBar.lua
Modules\RuneBar.lua
@@ -57,4 +59,6 @@ UI\RuneBarOptions.lua
UI\ProfileOptions.lua
UI\ExtraIconsOptionsUtil.lua
UI\ExtraIconsOptions.lua
+UI\SpellColorsPage.lua
UI\BuffBarsOptions.lua
+UI\ExternalBarsOptions.lua
diff --git a/Libs/LibSettingsBuilder/Controls/Rows.lua b/Libs/LibSettingsBuilder/Controls/Rows.lua
index 796131bf..8292f086 100644
--- a/Libs/LibSettingsBuilder/Controls/Rows.lua
+++ b/Libs/LibSettingsBuilder/Controls/Rows.lua
@@ -9,6 +9,7 @@ if not lib or not lib._loadState or not lib._loadState.open then
end
local internal = lib._internal
+local applyCanvasState = internal.applyCanvasState
local applyEmbedCanvasFrame = internal.applyEmbedCanvasFrame
local applyHeaderFrame = internal.applyHeaderFrame
local applyInfoRowFrame = internal.applyInfoRowFrame
@@ -44,15 +45,31 @@ function lib.PageActions(self, spec)
local categoryName = self._subcategoryNames[category]
or (category == self._rootCategory and self._rootCategoryName)
or ""
+ local attachToCategoryHeader = spec.attachToCategoryHeader ~= false
+ local hideTitle = spec.hideTitle
+ if hideTitle == nil then
+ hideTitle = attachToCategoryHeader
+ end
local initializer = createCustomListRowInitializer("SettingsListElementTemplate", {
_lsbKind = "pageActions",
name = spec.name or categoryName,
actions = spec.actions,
- hideTitle = true,
- attachToCategoryHeader = true,
- }, spec.height or 1, applyHeaderFrame)
+ hideTitle = hideTitle,
+ attachToCategoryHeader = attachToCategoryHeader,
+ }, spec.height or (attachToCategoryHeader and 1 or 28), applyHeaderFrame)
+
+ initializer._lsbEnabled = true
+ initializer.SetEnabled = function(controlInitializer, enabled)
+ controlInitializer._lsbEnabled = enabled
+ local activeFrame = controlInitializer._lsbActiveFrame
+ if activeFrame then
+ applyCanvasState(self, activeFrame, enabled)
+ end
+ end
+
initializer._lsbRefreshFrame = function(frame)
applyHeaderFrame(frame, initializer:GetData())
+ initializer:SetEnabled(initializer._lsbEnabled ~= false)
end
initializer._lsbResetFrame = hideHeaderActionButtons
return internal.addLayoutInitializer(self, spec, initializer, true)
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index b34a4f89..3a2ad015 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -300,4 +300,46 @@ describe("LibSettingsBuilder Builder", function()
assert.is_nil(page._builder)
assert.is_nil(page._root)
end)
+
+ it("registers declarative pageActions rows", function()
+ local sb = createBuilder({
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ name = "Main",
+ rows = {
+ {
+ id = "actions",
+ type = "pageActions",
+ name = "Spell Colors",
+ attachToCategoryHeader = false,
+ hideTitle = false,
+ actions = {
+ {
+ text = "Reset",
+ onClick = function() end,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ local page = assert(sb:GetPage("general", "main"))
+ local initializers = assert(page._category:GetLayout())._initializers
+ local initializer = assert(initializers[1])
+ local data = initializer:GetData()
+
+ assert.are.equal("pageActions", data._lsbKind)
+ assert.are.equal("Spell Colors", data.name)
+ assert.is_false(data.attachToCategoryHeader)
+ assert.is_false(data.hideTitle)
+ assert.are.equal("Reset", data.actions[1].text)
+ end)
end)
diff --git a/Locales/en.lua b/Locales/en.lua
index b735f41f..f4379187 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -70,6 +70,7 @@ L["POWER_BAR"] = "Power Bar"
L["RESOURCE_BAR"] = "Resource Bar"
L["RUNE_BAR"] = "Rune Bar"
L["AURA_BARS"] = "Aura Bars"
+L["EXTERNAL_BARS"] = "External Cooldowns"
L["EXTRA_ICONS"] = "Extra Icons"
--------------------------------------------------------------------------------
@@ -190,6 +191,14 @@ L["HEIGHT_OVERRIDE_DESC"] = "Override the default bar height. Set to 0 to use th
L["AURA_VERTICAL_SPACING"] = "Vertical Spacing"
L["AURA_VERTICAL_SPACING_DESC"] = "Vertical gap between aura bars. Set to 0 for no spacing."
+L["ENABLE_EXTERNAL_BARS"] = "Show external cooldowns as bars"
+L["ENABLE_EXTERNAL_BARS_DESC"] =
+ "Display Blizzard's tracked external defensive cooldowns as bars that can be positioned separately."
+L["HIDE_ORIGINAL_ICONS"] = "Hide the original icons"
+L["HIDE_ORIGINAL_ICONS_DESC"] =
+ "Hide Blizzard's original external defensive icons while keeping the external cooldown bars active."
+L["DISABLE_EXTERNAL_BARS_RELOAD"] = "Disabling external cooldown bars requires a UI reload. Reload now?"
+
L["SPELL_COLORS_SUBCAT"] = "Spell Colors"
L["SPELL_COLORS_DESC"] =
"Customize colors for individual spells. Spells that are tracked in the cooldown manager as bars will automatically appear here."
diff --git a/Modules/BuffBars.lua b/Modules/BuffBars.lua
index deba8102..ab4eff5e 100644
--- a/Modules/BuffBars.lua
+++ b/Modules/BuffBars.lua
@@ -6,9 +6,16 @@ local _, ns = ...
local BarMixin = ns.BarMixin
local FrameUtil = ns.FrameUtil
local ChainRightPoint = BarMixin.FrameProto.ChainRightPoint
+local StyleChildBar = ns.BarStyle.StyleChildBar
local BuffBars = ns.Addon:NewModule("BuffBars")
ns.Addon.BuffBars = BuffBars
+local SPELL_COLOR_SCOPE = ns.Constants.SCOPE_BUFFBARS
+
+local function getSpellColors()
+ return ns.SpellColors.Get(SPELL_COLOR_SCOPE)
+end
+
---@class ECM_BuffBarMixin : Frame
---@field __ecmHooked boolean
---@field Bar StatusBar
@@ -49,229 +56,13 @@ local function getChildrenOrdered(viewer)
return result
end
---- Strips circular masks and hides overlay/border to produce a square icon.
---- The heavy cleanup (mask removal, pcalls, region iteration) is cached on the
---- frame via `__ecmSquareStyled` so it only runs once per icon frame.
----@param iconFrame Frame|nil
----@param iconTexture Texture|nil
----@param iconOverlay Texture|nil
----@param debuffBorder Texture|nil
-local function applySquareIconStyle(iconFrame, iconTexture, iconOverlay, debuffBorder)
- if not iconFrame or iconFrame.__ecmSquareStyled or not iconTexture then
- return
- end
-
- iconTexture:SetTexCoord(0, 1, 0, 1)
-
- -- Remove circular masks from the icon texture
- if iconTexture.GetNumMaskTextures and iconTexture.RemoveMaskTexture and iconTexture.GetMaskTexture then
- for i = (iconTexture:GetNumMaskTextures() or 0), 1, -1 do
- local mask = iconTexture:GetMaskTexture(i)
- if mask then
- iconTexture:RemoveMaskTexture(mask)
- if mask.Hide then mask:Hide() end
- end
- end
- elseif iconTexture.SetMask then
- pcall(iconTexture.SetMask, iconTexture, nil)
- end
-
- -- Remove mask regions from the icon frame
- if iconFrame.GetRegions and iconTexture.RemoveMaskTexture then
- for _, region in ipairs({ iconFrame:GetRegions() }) do
- if region and region.IsObjectType and region:IsObjectType("MaskTexture") then
- pcall(iconTexture.RemoveMaskTexture, iconTexture, region)
- if region.Hide then region:Hide() end
- end
- end
- end
-
- if iconOverlay then iconOverlay:Hide() end
- if debuffBorder then debuffBorder:Hide() end
-
- iconFrame.__ecmSquareStyled = true
-end
-
-local function styleBarHeight(frame, bar, iconFrame, config, globalConfig)
- local height = (config and config.height) or (globalConfig and globalConfig.barHeight) or 15
- if height <= 0 then
- return
- end
- FrameUtil.LazySetHeight(frame, height)
- FrameUtil.LazySetHeight(bar, height)
- if iconFrame then
- FrameUtil.LazySetHeight(iconFrame, height)
- FrameUtil.LazySetWidth(iconFrame, height)
- end
-end
-
-local function styleBarBackground(frame, barBG, config, globalConfig)
- if not barBG then
- return
- end
-
- -- One-time setup: reparent BarBG to the outer frame and hook SetPoint
- -- so Blizzard cannot override our anchors. SetAllPoints does not fire
- -- SetPoint hooks, so no re-entrancy guard is needed.
- if not barBG.__ecmBGHooked then
- barBG.__ecmBGHooked = true
- barBG:SetParent(frame)
- hooksecurefunc(barBG, "SetPoint", function()
- barBG:ClearAllPoints()
- barBG:SetAllPoints(frame)
- end)
- end
-
- local bgColor = (config and config.bgColor)
- or (globalConfig and globalConfig.barBgColor)
- or ns.Constants.COLOR_BLACK
- barBG:SetTexture(ns.Constants.FALLBACK_TEXTURE)
- barBG:SetVertexColor(bgColor.r, bgColor.g, bgColor.b, bgColor.a)
- barBG:ClearAllPoints()
- barBG:SetAllPoints(frame)
- barBG:SetDrawLayer("BACKGROUND", 0)
-end
-
---- Resolves the spell color for a bar, handling secret values with retry.
---- Returns true if the module's _editLocked flag was set by this call.
-local function styleBarColor(module, frame, bar, globalConfig, retryCount)
- local textureName = globalConfig and globalConfig.texture
- FrameUtil.LazySetStatusBarTexture(bar, FrameUtil.GetTexture(textureName))
-
- local barColor = ns.SpellColors.GetColorForBar(frame)
- local spellName = bar.Name and bar.Name.GetText and bar.Name:GetText()
- local spellID = frame.cooldownInfo and frame.cooldownInfo.spellID
- local cooldownID = frame.cooldownID
- local textureFileID = FrameUtil.GetIconTextureFileID(frame)
-
- -- When in a raid instance, and after exiting combat, all identifying
- -- values may remain secret. Lock editing only when every key is
- -- unusable. With four tiers (name, spellID, cooldownID, texture)
- -- the colour lookup is much more resilient to partial secrecy.
- local allSecret = issecretvalue(spellName)
- and issecretvalue(spellID)
- and issecretvalue(cooldownID)
- and issecretvalue(textureFileID)
- module._editLocked = module._editLocked or allSecret
-
- if allSecret and not InCombatLockdown() then
- if retryCount < 3 then
- if frame._ecmColorRetryTimer then
- frame._ecmColorRetryTimer:Cancel()
- end
- frame._ecmColorRetryTimer = C_Timer.NewTimer(1, function()
- frame._ecmColorRetryTimer = nil
- styleBarColor(module, frame, bar, globalConfig, retryCount + 1)
- end)
- -- Don't apply any colour while retries are pending — preserve
- -- the bar's existing colour rather than clobbering it with the
- -- default while we wait for secrets to clear.
- return
- elseif ns.IsDebugEnabled() and not module._warned then
- ns.Log(ns.Constants.BUFFBARS, "All identifying keys are secret outside of combat.")
- module._warned = true
- end
- end
-
- if frame._ecmColorRetryTimer then
- frame._ecmColorRetryTimer:Cancel()
- frame._ecmColorRetryTimer = nil
- end
-
- if barColor == nil and not allSecret then
- barColor = ns.SpellColors.GetDefaultColor()
- end
- if barColor then
- FrameUtil.LazySetStatusBarColor(bar, barColor.r, barColor.g, barColor.b, 1.0)
- end
-end
-
-local function styleBarIcon(frame, iconFrame, config)
- local showIcon = config and config.showIcon ~= false
-
- if iconFrame then
- FrameUtil.LazySetAnchors(iconFrame, {
- { "TOPLEFT", frame, "TOPLEFT", 0, 0 },
- })
- local iconTexture = FrameUtil.GetIconTexture(frame)
- local iconOverlay = FrameUtil.GetIconOverlay(frame)
- applySquareIconStyle(iconFrame, iconTexture, iconOverlay, frame.DebuffBorder)
- iconFrame:SetShown(showIcon)
- if iconTexture then
- iconTexture:SetShown(showIcon)
- end
- end
-
- if frame.DebuffBorder then
- FrameUtil.LazySetAlpha(frame.DebuffBorder, 0)
- frame.DebuffBorder:Hide()
- end
- if iconFrame and iconFrame.Applications then
- FrameUtil.LazySetAlpha(iconFrame.Applications, showIcon and 1 or 0)
- end
-end
-
-local function styleBarAnchors(frame, bar, iconFrame, config)
- local showSpellName = config and config.showSpellName ~= false
- local showDuration = config and config.showDuration ~= false
- if bar.Name then
- bar.Name:SetShown(showSpellName)
- end
- if bar.Duration then
- bar.Duration:SetShown(showDuration)
- end
-
- local iconVisible = iconFrame and iconFrame:IsShown()
- local barLeftAnchor = iconVisible and iconFrame or frame
- local barLeftPoint = iconVisible and "TOPRIGHT" or "TOPLEFT"
- FrameUtil.LazySetAnchors(bar, {
- { "TOPLEFT", barLeftAnchor, barLeftPoint, 0, 0 },
- { "TOPRIGHT", frame, "TOPRIGHT", 0, 0 },
- })
-
- FrameUtil.LazySetAnchors(bar.Name, {
- { "LEFT", bar, "LEFT", ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
- { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
- })
-
- if bar.Duration then
- FrameUtil.LazySetAnchors(bar.Duration, {
- { "RIGHT", bar, "RIGHT", -ns.Constants.BUFFBARS_TEXT_PADDING, 0 },
- })
- end
-end
-
---- Applies all sizing, styling, visibility, and anchoring to a single buff bar
---- child frame. Lazy setters ensure no-ops when values haven't changed.
-local function styleChildFrame(module, frame, config, globalConfig)
- if not (frame and frame.__ecmHooked) then
- ns.DebugAssert(false, "Attempted to style a child frame that wasn't hooked.")
- return
- end
-
- local bar = frame.Bar
- local iconFrame = frame.Icon
-
- styleBarHeight(frame, bar, iconFrame, config, globalConfig)
-
- bar.Pip:Hide()
- bar.Pip:SetTexture(nil)
-
- styleBarBackground(frame, FrameUtil.GetBarBackground(bar), config, globalConfig)
- styleBarColor(module, frame, bar, globalConfig, 0)
-
- FrameUtil.ApplyFont(bar.Name, globalConfig, config)
- FrameUtil.ApplyFont(bar.Duration, globalConfig, config)
-
- styleBarIcon(frame, iconFrame, config)
- styleBarAnchors(frame, bar, iconFrame, config)
-end
-
local function hookChildFrame(child, module)
if child.__ecmHooked then
return
end
+ local spellColors = getSpellColors()
+
-- Hook various parts of the blizzard frames to ensure our modifications aren't removed or overridden.
-- Each hook guards against _layoutRunning to prevent recursion from our lazy setters.
hooksecurefunc(child, "SetPoint", function()
@@ -286,7 +77,7 @@ local function hookChildFrame(child, module)
if cached then
FrameUtil.LazySetAnchors(child, cached)
end
- styleChildFrame(module, child, module:GetModuleConfig(), module:GetGlobalConfig())
+ StyleChildBar(module, child, module:GetModuleConfig(), module:GetGlobalConfig(), spellColors)
module._layoutRunning = nil
ns.Runtime.RequestLayout("BuffBars:SetPoint:hook", { secondPass = true })
end)
@@ -296,7 +87,7 @@ local function hookChildFrame(child, module)
return
end
module._layoutRunning = true
- styleChildFrame(module, child, module:GetModuleConfig(), module:GetGlobalConfig())
+ StyleChildBar(module, child, module:GetModuleConfig(), module:GetGlobalConfig(), spellColors)
module._layoutRunning = nil
ns.Runtime.RequestLayout("BuffBars:OnShow:child", { secondPass = true })
end)
@@ -394,16 +185,17 @@ function BuffBars:UpdateLayout(why)
local viewer = _G["BuffBarCooldownViewer"]
local globalConfig = self:GetGlobalConfig()
local cfg = self:GetModuleConfig()
+ local spellColors = getSpellColors()
if why == "PLAYER_SPECIALIZATION_CHANGED" or why == "ProfileChanged" then
- ns.SpellColors.ClearDiscoveredKeys()
+ spellColors:ClearDiscoveredKeys()
end
-- Discover bars regardless of visibility so the spell colours options
-- panel has the full list even when hidden (e.g. resting).
local visibleChildren = getChildrenOrdered(viewer)
for _, entry in ipairs(visibleChildren) do
- ns.SpellColors.DiscoverBar(entry.frame)
+ spellColors:DiscoverBar(entry.frame)
end
if not self:ShouldShow() then
@@ -461,7 +253,7 @@ function BuffBars:UpdateLayout(why)
local ok, err = pcall(function()
for _, entry in ipairs(visibleChildren) do
hookChildFrame(entry.frame, self)
- styleChildFrame(self, entry.frame, cfg, globalConfig)
+ StyleChildBar(self, entry.frame, cfg, globalConfig, spellColors)
end
layoutBars(visibleChildren, viewer, growsUp, verticalSpacing)
@@ -556,10 +348,6 @@ end
function BuffBars:OnEnable()
self._warned = false
- ns.SpellColors.SetConfigAccessor(function()
- return self:GetModuleConfig()
- end)
-
self:EnsureFrame()
ns.Runtime.RegisterFrame(self)
@@ -575,7 +363,6 @@ function BuffBars:OnEnable()
end
function BuffBars:OnDisable()
- ns.SpellColors.SetConfigAccessor(nil)
self:UnregisterAllEvents()
ns.Runtime.UnregisterFrame(self)
end
diff --git a/Modules/ExternalBars.lua b/Modules/ExternalBars.lua
new file mode 100644
index 00000000..afc7e1ef
--- /dev/null
+++ b/Modules/ExternalBars.lua
@@ -0,0 +1,644 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local BarMixin = ns.BarMixin
+local FrameUtil = ns.FrameUtil
+local FrameProto = BarMixin.FrameProto
+local StyleChildBar = ns.BarStyle.StyleChildBar
+local C = ns.Constants
+local ExternalBars = ns.Addon:NewModule("ExternalBars")
+ns.Addon.ExternalBars = ExternalBars
+
+local PLAYER_UNIT = "player"
+local SPELL_COLOR_SCOPE = C.SCOPE_EXTERNALBARS
+local canAccessTable = _G.canaccesstable
+local secondsToTimeAbbrev = _G.SecondsToTimeAbbrev
+
+local function getSpellColors()
+ return ns.SpellColors.Get(SPELL_COLOR_SCOPE)
+end
+
+---@class ECM_ExternalAuraState Cached external aura data keyed by Blizzard's aura array position.
+---@field auraInstanceID number|nil Aura instance identifier forwarded only to Blizzard aura APIs.
+---@field index number Aura array position currently bound to this cached aura state.
+---@field name string|nil Non-secret aura name used for color lookup and optional display.
+---@field spellID number|nil Non-secret spell ID used for spell-color lookup.
+---@field texture string|number|nil Aura icon texture forwarded to widget APIs.
+---@field duration number|nil Aura duration forwarded to the cooldown widget.
+---@field expirationTime number|nil Aura expiration time used only when non-secret duration text is allowed.
+---@field timeMod number|nil Aura time modifier forwarded to the cooldown widget.
+---@field durationIsSecret boolean Whether the packed aura duration is secret.
+---@field canShowDurationText boolean Whether duration text can be refreshed safely in Lua.
+---@field hasRenderableDuration boolean Whether the cooldown widget can be configured for this aura.
+
+---@class ECM_ExternalBarStatusBar : StatusBar Shared-status bar surface for one external aura row.
+---@field Name FontString Spell name text.
+---@field Duration FontString Remaining duration text.
+---@field Pip Texture Hidden compatibility texture expected by `BarStyle.StyleChildBar`.
+
+---@class ECM_ExternalBarMixin : Frame Reusable bar row styled by the shared BuffBars helpers.
+---@field __ecmHooked boolean Whether the shared child-bar styling is allowed to target this frame.
+---@field Bar ECM_ExternalBarStatusBar Inner status bar surface.
+---@field Cooldown Cooldown Cooldown overlay rendering the draining fill.
+---@field Icon Frame Icon frame containing the texture regions expected by `FrameUtil`.
+---@field cooldownInfo { spellID: number|nil } Spell metadata consumed by `SpellColors`.
+---@field _ecmAuraIndex number Aura array position currently bound to this pooled bar.
+---@field _iconTexture Texture Cached icon texture for this bar.
+
+local function getViewer()
+ return _G["ExternalDefensivesFrame"]
+end
+
+---@param point string|nil
+---@return boolean
+local function pointGrowsUp(point)
+ return point == "BOTTOMLEFT" or point == "BOTTOM" or point == "BOTTOMRIGHT"
+end
+
+---@param remaining number
+---@return string
+local function formatDurationText(remaining)
+ if remaining <= 0 then
+ return ""
+ end
+
+ if type(secondsToTimeAbbrev) == "function" then
+ local text = secondsToTimeAbbrev(remaining)
+ return text or ""
+ end
+
+ if remaining >= 60 then
+ local minutes = math.floor(remaining / 60)
+ local seconds = math.floor(remaining % 60)
+ return string.format("%d:%02d", minutes, seconds)
+ end
+
+ if remaining >= 10 then
+ return string.format("%d", math.floor(remaining + 0.5))
+ end
+
+ return string.format("%.1f", remaining)
+end
+
+---@param bars ECM_ExternalBarMixin[]
+---@param container Frame
+---@param growsUp boolean
+---@param verticalSpacing number
+local function layoutBars(bars, container, growsUp, verticalSpacing)
+ local previous
+
+ local function anchorBar(bar)
+ local selfEdge = growsUp and "BOTTOM" or "TOP"
+ local relativeEdge = previous and (growsUp and "TOP" or "BOTTOM") or selfEdge
+ local anchor = previous or container
+ local spacing = not previous and 0 or (growsUp and verticalSpacing or -verticalSpacing)
+
+ FrameUtil.LazySetAnchors(bar, {
+ { selfEdge .. "LEFT", anchor, relativeEdge .. "LEFT", 0, spacing },
+ { selfEdge .. "RIGHT", anchor, relativeEdge .. "RIGHT", 0, spacing },
+ })
+ previous = bar
+ end
+
+ if growsUp then
+ for index = #bars, 1, -1 do
+ anchorBar(bars[index])
+ end
+ return
+ end
+
+ for _, bar in ipairs(bars) do
+ anchorBar(bar)
+ end
+end
+
+---@param moduleConfig ECM_ExternalBarsConfig|nil
+---@param globalConfig ECM_GlobalConfig|nil
+---@return ECM_ExternalBarsConfig|nil
+function ExternalBars:_GetStyleConfig(moduleConfig, globalConfig)
+ if not moduleConfig or moduleConfig.height ~= 0 then
+ return moduleConfig
+ end
+
+ local styleConfig = self._styleConfig or {}
+ wipe(styleConfig)
+
+ for key, value in pairs(moduleConfig) do
+ styleConfig[key] = value
+ end
+
+ styleConfig.height = (globalConfig and globalConfig.barHeight) or C.DEFAULT_BAR_HEIGHT
+ self._styleConfig = styleConfig
+ return styleConfig
+end
+
+---@param styleConfig ECM_ExternalBarsConfig|nil
+---@param globalConfig ECM_GlobalConfig|nil
+---@return number
+function ExternalBars:_GetBarHeight(styleConfig, globalConfig)
+ return (styleConfig and styleConfig.height) or (globalConfig and globalConfig.barHeight) or C.DEFAULT_BAR_HEIGHT
+end
+
+---@param hidden boolean
+function ExternalBars:_SetOriginalIconsHidden(hidden)
+ local viewer = getViewer()
+ if not viewer then
+ return
+ end
+
+ if hidden then
+ self._originalIconsHidden = true
+ viewer:SetAlpha(0)
+ viewer:EnableMouse(false)
+ return
+ end
+
+ if self._originalIconsHidden then
+ local alpha = 1
+
+ self._originalIconsHidden = nil
+
+ if ns.Runtime and ns.Runtime.GetDesiredAlpha then
+ alpha = ns.Runtime.GetDesiredAlpha()
+ end
+
+ viewer:SetAlpha(alpha)
+ viewer:EnableMouse(alpha > 0)
+
+ if ns.Runtime and ns.Runtime.RequestLayout then
+ ns.Runtime.RequestLayout("ExternalBars:OriginalIconsShown")
+ end
+ end
+end
+
+function ExternalBars:_RefreshOriginalIconsState()
+ local moduleConfig = self:GetModuleConfig()
+ self:_SetOriginalIconsHidden(moduleConfig and moduleConfig.hideOriginalIcons == true)
+end
+
+function ExternalBars:_StopDurationTicker()
+ if self._durationTicker then
+ self._durationTicker:Cancel()
+ self._durationTicker = nil
+ end
+end
+
+---@param bar ECM_ExternalBarMixin
+---@param auraState ECM_ExternalAuraState|nil
+---@param showDuration boolean|nil
+function ExternalBars:_RefreshBarDurationText(bar, auraState, showDuration)
+ local durationText = bar and bar.Bar and bar.Bar.Duration
+ if not durationText then
+ return
+ end
+
+ if showDuration == false or not auraState or not auraState.canShowDurationText then
+ durationText:SetText(nil)
+ durationText:Hide()
+ return
+ end
+
+ local remaining = auraState.expirationTime - GetTime()
+ if remaining < 0 then
+ remaining = 0
+ end
+
+ durationText:SetText(formatDurationText(remaining))
+ durationText:Show()
+end
+
+function ExternalBars:_RefreshDurationTexts()
+ local moduleConfig = self:GetModuleConfig()
+ if not moduleConfig or moduleConfig.showDuration == false then
+ self:_StopDurationTicker()
+ return
+ end
+
+ local hasEligibleBars = false
+ local barPool = self._barPool or {}
+ local auraStates = self._auraStates or {}
+ local activeAuraCount = self._activeAuraCount or 0
+
+ for index = 1, activeAuraCount do
+ local bar = barPool[index]
+ local auraState = auraStates[index]
+ if bar and bar:IsShown() and auraState and auraState.canShowDurationText then
+ hasEligibleBars = true
+ end
+ if bar then
+ self:_RefreshBarDurationText(bar, auraState, true)
+ end
+ end
+
+ if not hasEligibleBars then
+ self:_StopDurationTicker()
+ end
+end
+
+function ExternalBars:_RestartDurationTicker()
+ self:_StopDurationTicker()
+
+ local moduleConfig = self:GetModuleConfig()
+ if not moduleConfig or moduleConfig.showDuration == false then
+ return
+ end
+
+ local auraStates = self._auraStates or {}
+ local activeAuraCount = self._activeAuraCount or 0
+ for index = 1, activeAuraCount do
+ local auraState = auraStates[index]
+ if auraState and auraState.canShowDurationText then
+ self._durationTicker = C_Timer.NewTicker(0.1, function()
+ self:_RefreshDurationTexts()
+ end)
+ return
+ end
+ end
+end
+
+---@param index number
+---@return ECM_ExternalBarMixin
+function ExternalBars:_ensureBar(index)
+ self._barPool = self._barPool or {}
+
+ local bar = self._barPool[index]
+ if bar then
+ return bar
+ end
+
+ local container = self.InnerFrame
+ ---@cast container Frame
+ bar = CreateFrame("Frame", nil, container)
+ bar:SetFrameStrata("MEDIUM")
+ bar:SetFrameLevel(container:GetFrameLevel() + 1)
+ bar.__ecmHooked = true
+ bar.cooldownInfo = {}
+
+ bar.Bar = CreateFrame("StatusBar", nil, bar)
+ bar.Bar:SetAllPoints(bar)
+ bar.Bar:SetFrameLevel(bar:GetFrameLevel() + 1)
+ bar.Bar:SetMinMaxValues(0, 1)
+ bar.Bar:SetValue(1)
+
+ local barBackground = bar.Bar:CreateTexture(nil, "BACKGROUND")
+ barBackground:SetAllPoints(bar.Bar)
+ barBackground:SetAtlas("UI-HUD-CoolDownManager-Bar-BG")
+
+ bar.Bar.Pip = bar.Bar:CreateTexture(nil, "OVERLAY")
+ bar.Bar.Pip:Hide()
+
+ bar.Icon = CreateFrame("Frame", nil, bar)
+ bar.Icon:SetFrameLevel(bar:GetFrameLevel() + 3)
+
+ local iconTexture = bar.Icon:CreateTexture(nil, "ARTWORK")
+ iconTexture:SetAllPoints(bar.Icon)
+ iconTexture:SetTexCoord(0, 1, 0, 1)
+ bar._iconTexture = iconTexture
+
+ bar.Icon.Applications = bar.Icon:CreateTexture(nil, "ARTWORK")
+ bar.Icon.Applications:SetAllPoints(bar.Icon)
+ bar.Icon.Applications:SetAlpha(0)
+
+ local iconOverlay = bar.Icon:CreateTexture(nil, "OVERLAY")
+ iconOverlay:SetAllPoints(bar.Icon)
+ iconOverlay:SetAtlas("UI-HUD-CoolDownManager-IconOverlay")
+
+ bar.TextFrame = CreateFrame("Frame", nil, bar)
+ bar.TextFrame:SetAllPoints(bar)
+ bar.TextFrame:SetFrameLevel(bar.Bar:GetFrameLevel() + 5)
+
+ bar.Bar.Name = bar.TextFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ bar.Bar.Name:SetJustifyH("LEFT")
+ bar.Bar.Name:SetJustifyV("MIDDLE")
+ bar.Bar.Name:SetWordWrap(false)
+
+ bar.Bar.Duration = bar.TextFrame:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
+ bar.Bar.Duration:SetJustifyH("RIGHT")
+ bar.Bar.Duration:SetJustifyV("MIDDLE")
+ bar.Bar.Duration:SetWordWrap(false)
+
+ bar.Cooldown = CreateFrame("Cooldown", nil, bar.Bar, "CooldownFrameTemplate")
+ bar.Cooldown:SetAllPoints(bar.Bar)
+ bar.Cooldown:SetFrameLevel(bar.Bar:GetFrameLevel() + 2)
+ bar.Cooldown:SetDrawSwipe(true)
+ bar.Cooldown:SetDrawEdge(false)
+ bar.Cooldown:SetDrawBling(false)
+ bar.Cooldown:SetReverse(true)
+ bar.Cooldown:SetHideCountdownNumbers(true)
+ bar.Cooldown:SetSwipeColor(0, 0, 0, 1)
+
+ bar:Hide()
+ self._barPool[index] = bar
+ return bar
+end
+
+---@param activeCount number
+function ExternalBars:_hideExcessBars(activeCount)
+ local barPool = self._barPool or {}
+ for index = activeCount + 1, #barPool do
+ local bar = barPool[index]
+ if bar then
+ if bar._ecmColorRetryTimer then
+ bar._ecmColorRetryTimer:Cancel()
+ bar._ecmColorRetryTimer = nil
+ end
+ bar.cooldownInfo.spellID = nil
+ bar.Bar.Name:SetText(nil)
+ bar.Bar.Duration:SetText(nil)
+ bar.Bar.Duration:Hide()
+ bar.Cooldown:Clear()
+ bar:Hide()
+ end
+ end
+end
+
+---@param bar ECM_ExternalBarMixin
+---@param auraState ECM_ExternalAuraState
+---@param moduleConfig ECM_ExternalBarsConfig|nil
+---@param globalConfig ECM_GlobalConfig|nil
+---@param styleConfig ECM_ExternalBarsConfig|nil
+---@param spellColors ECM_SpellColorStore
+function ExternalBars:_ConfigureBar(bar, auraState, moduleConfig, globalConfig, styleConfig, spellColors)
+ bar._ecmAuraIndex = auraState.index
+ bar.cooldownInfo.spellID = auraState.spellID
+ bar._iconTexture:SetTexture(auraState.texture)
+ bar.Bar.Name:SetText(auraState.name)
+
+ StyleChildBar(self, bar, styleConfig, globalConfig, spellColors)
+ spellColors:DiscoverBar(bar)
+
+ self:_RefreshBarDurationText(bar, auraState, moduleConfig and moduleConfig.showDuration ~= false)
+
+ if auraState.hasRenderableDuration then
+ bar.Cooldown:SetCooldownDuration(auraState.duration, auraState.timeMod)
+ else
+ bar.Cooldown:Clear()
+ end
+
+ bar:Show()
+end
+
+function ExternalBars:GetActiveSpellData()
+ local result = {}
+ local auraStates = self._auraStates or {}
+ local barPool = self._barPool or {}
+ local activeAuraCount = self._activeAuraCount or 0
+
+ for index = 1, activeAuraCount do
+ local auraState = auraStates[index]
+ if auraState then
+ local bar = barPool[index]
+ local textureFileID = bar and FrameUtil.GetIconTextureFileID(bar) or nil
+ local key = ns.SpellColors.MakeKey(auraState.name, auraState.spellID, nil, textureFileID)
+ if key then
+ result[#result + 1] = key
+ end
+ end
+ end
+
+ return result
+end
+
+---@return boolean isEditLocked
+---@return string|nil reason
+function ExternalBars:IsEditLocked()
+ local reason = InCombatLockdown() and "combat" or (self._editLocked and "secrets") or nil
+ return reason ~= nil, reason
+end
+
+function ExternalBars:ShouldShow()
+ if not FrameProto.ShouldShow(self) then
+ return false
+ end
+
+ local viewer = getViewer()
+ return viewer ~= nil and viewer:IsShown() and (self._activeAuraCount or 0) > 0
+end
+
+function ExternalBars:CreateFrame()
+ local frame = CreateFrame("Frame", "ECMExternalBars", UIParent)
+ frame:SetFrameStrata("MEDIUM")
+ frame:SetSize(1, 1)
+ return frame
+end
+
+function ExternalBars:HookViewer()
+ local viewer = getViewer()
+ if not viewer or self._viewerHooked then
+ return
+ end
+
+ self._viewerHooked = true
+
+ hooksecurefunc(viewer, "UpdateAuras", function()
+ if self:IsEnabled() then
+ self:OnExternalAurasUpdated()
+ end
+ end)
+
+ viewer:HookScript("OnShow", function()
+ if not self:IsEnabled() then
+ return
+ end
+ self:_RefreshOriginalIconsState()
+ self:OnExternalAurasUpdated()
+ end)
+
+ viewer:HookScript("OnHide", function()
+ if not self:IsEnabled() then
+ return
+ end
+ self._activeAuraCount = 0
+ self:_hideExcessBars(0)
+ self:_StopDurationTicker()
+ ns.Runtime.RequestLayout("ExternalBars:viewer:OnHide")
+ end)
+
+ ns.Log(self.Name, "Hooked ExternalDefensivesFrame")
+end
+
+function ExternalBars:OnExternalAurasUpdated()
+ self:HookViewer()
+ self:_RefreshOriginalIconsState()
+
+ local viewer = getViewer()
+ local auraInfo = viewer and viewer.auraInfo or nil
+ local auraStates = self._auraStates or {}
+ self._auraStates = auraStates
+
+ local activeAuraCount = 0
+ if type(auraInfo) == "table" then
+ for index, info in ipairs(auraInfo) do
+ activeAuraCount = index
+
+ local auraState = auraStates[index] or {}
+ auraStates[index] = auraState
+
+ local auraInstanceID = info.auraInstanceID
+ local auraName = nil
+ local spellID = nil
+ local auraData = C_UnitAuras.GetAuraDataByAuraInstanceID(PLAYER_UNIT, auraInstanceID)
+ local accessibleAuraData = canAccessTable(auraData) and auraData or nil
+ if accessibleAuraData then
+ local auraDataName = accessibleAuraData.name
+ if not issecretvalue(auraDataName) and auraDataName ~= nil and auraDataName ~= "" then
+ auraName = auraDataName
+ end
+
+ local auraSpellID = accessibleAuraData.spellId
+ if not issecretvalue(auraSpellID) and auraSpellID ~= nil then
+ spellID = auraSpellID
+ end
+ end
+
+ local duration = info.duration
+ local expirationTime = info.expirationTime
+ local durationIsSecret = issecretvalue(duration)
+ local expirationTimeIsSecret = issecretvalue(expirationTime)
+
+ auraState.index = index
+ auraState.auraInstanceID = auraInstanceID
+ auraState.name = auraName
+ auraState.spellID = spellID
+ auraState.texture = info.texture
+ auraState.duration = duration
+ auraState.expirationTime = expirationTime
+ auraState.timeMod = info.timeMod
+ auraState.durationIsSecret = durationIsSecret
+ auraState.canShowDurationText = not durationIsSecret
+ and not expirationTimeIsSecret
+ and type(duration) == "number"
+ and duration > 0
+ and type(expirationTime) == "number"
+ auraState.hasRenderableDuration = durationIsSecret or (type(duration) == "number" and duration > 0)
+ end
+ end
+
+ self._activeAuraCount = activeAuraCount
+ ns.Runtime.RequestLayout("ExternalBars:UpdateAuras")
+end
+
+---@param why string|nil
+---@return boolean
+function ExternalBars:UpdateLayout(why)
+ local frame = self.InnerFrame
+ if not frame then
+ return false
+ end
+
+ self:HookViewer()
+ self:_RefreshOriginalIconsState()
+
+ local spellColors = getSpellColors()
+
+ if why == "PLAYER_SPECIALIZATION_CHANGED" or why == "ProfileChanged" then
+ spellColors:ClearDiscoveredKeys()
+ end
+
+ local globalConfig = self:GetGlobalConfig()
+ local moduleConfig = self:GetModuleConfig()
+ local styleConfig = self:_GetStyleConfig(moduleConfig, globalConfig)
+ local params = self:ApplyFramePosition()
+ if not params then
+ self:_hideExcessBars(0)
+ self:_StopDurationTicker()
+ return false
+ end
+
+ if params.width then
+ FrameUtil.LazySetWidth(frame, params.width)
+ end
+
+ self._editLocked = false
+
+ local activeBars = self._activeBars or {}
+ wipe(activeBars)
+ self._activeBars = activeBars
+
+ local activeAuraCount = self._activeAuraCount or 0
+ local auraStates = self._auraStates or {}
+ local ok, err = pcall(function()
+ for index = 1, activeAuraCount do
+ local auraState = auraStates[index]
+ if auraState then
+ local bar = self:_ensureBar(index)
+ self:_ConfigureBar(bar, auraState, moduleConfig, globalConfig, styleConfig, spellColors)
+ activeBars[#activeBars + 1] = bar
+
+ local textureFileID = FrameUtil.GetIconTextureFileID(bar)
+ local textureIsSecret = issecretvalue(textureFileID)
+ if auraState.name == nil and auraState.spellID == nil and (textureIsSecret or textureFileID == nil) then
+ self._editLocked = true
+ end
+ end
+ end
+
+ self:_hideExcessBars(#activeBars)
+ layoutBars(activeBars, frame, pointGrowsUp(params.anchorPoint), math.max(0, moduleConfig and moduleConfig.verticalSpacing or 0))
+ end)
+
+ if not self._editLocked then
+ self._warned = false
+ end
+
+ if not ok then
+ self:_hideExcessBars(0)
+ self:_StopDurationTicker()
+ ns.DebugAssert(false, "Error styling external bars: " .. tostring(err))
+ return false
+ end
+
+ local barCount = #activeBars
+ local barHeight = self:_GetBarHeight(styleConfig, globalConfig)
+ local verticalSpacing = math.max(0, moduleConfig and moduleConfig.verticalSpacing or 0)
+ local totalHeight = (barCount * barHeight) + (math.max(0, barCount - 1) * verticalSpacing)
+ FrameUtil.LazySetHeight(frame, totalHeight)
+
+ self:_RestartDurationTicker()
+
+ ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
+ barCount = barCount,
+ anchorPoint = params.anchorPoint,
+ offsetX = params.offsetX,
+ offsetY = params.offsetY,
+ })
+ return true
+end
+
+function ExternalBars:OnInitialize()
+ BarMixin.AddFrameMixin(self, "ExternalBars")
+end
+
+function ExternalBars:OnEnable()
+ self._barPool = self._barPool or {}
+ self._activeBars = self._activeBars or {}
+ self._auraStates = self._auraStates or {}
+ self._activeAuraCount = 0
+ self._warned = false
+
+ self:EnsureFrame()
+ ns.Runtime.RegisterFrame(self)
+
+ C_Timer.After(0.1, function()
+ if not self:IsEnabled() then
+ return
+ end
+
+ self:HookViewer()
+ self:_RefreshOriginalIconsState()
+ self:OnExternalAurasUpdated()
+ ns.Runtime.RequestLayout("ExternalBars:OnEnable")
+ end)
+end
+
+function ExternalBars:OnDisable()
+ self:UnregisterAllEvents()
+ self:_StopDurationTicker()
+ self:_SetOriginalIconsHidden(false)
+ self._activeAuraCount = 0
+ self:_hideExcessBars(0)
+
+ ns.Runtime.UnregisterFrame(self)
+end
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index f32ab929..7111b418 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -34,6 +34,8 @@ local VIEWER_REGISTRY = {
main = { blizzFrameKey = "EssentialCooldownViewer" },
}
+-- Keep main first: utility may reuse the current pass's main viewer offset when
+-- its cached Blizzard anchor is not relative to the main viewer.
local VIEWER_ORDER = { "main", "utility" }
local function cacheOriginalPoint(viewerState, blizzFrame)
@@ -424,6 +426,15 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
local container = vs.container
cacheOriginalPoint(vs, blizzFrame)
+ local sharedOffsetX = 0
+ if viewerKey == "utility" and vs.originalPoint then
+ local mainBlizzFrame = _G[VIEWER_REGISTRY.main.blizzFrameKey]
+ local isAnchoredToMainViewer = mainBlizzFrame and vs.originalPoint[2] == mainBlizzFrame or false
+ if not isAnchoredToMainViewer then
+ sharedOffsetX = self._mainViewerOffsetX or 0
+ end
+ end
+
-- Resolve entries to displayable items
local items
if not blizzFrame or not blizzFrame:IsShown() or isEditing or #entries == 0 then
@@ -434,7 +445,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
if #items == 0 then
-- Restore viewer position and hide container
- applyViewerPoint(vs, blizzFrame)
+ applyViewerPoint(vs, blizzFrame, sharedOffsetX)
if isEditing then
vs.originalPoint = nil
end
@@ -497,7 +508,10 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
local viewerWidth = numActiveViewerIcons > 0 and blizzFrame:GetWidth() or 0
local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
- applyViewerPoint(vs, blizzFrame, viewerOffsetX)
+ if viewerKey == "main" then
+ self._mainViewerOffsetX = viewerOffsetX
+ end
+ applyViewerPoint(vs, blizzFrame, sharedOffsetX + viewerOffsetX)
-- Position and configure each icon
local borderScale = ns.Constants.EXTRA_ICON_BORDER_SCALE
@@ -545,6 +559,8 @@ function ExtraIcons:UpdateLayout(why)
return false
end
+ self._mainViewerOffsetX = 0
+
local shouldShow = self:ShouldShow()
local moduleConfig = self:GetModuleConfig()
local isEditing = self._isEditModeActive
@@ -780,5 +796,6 @@ function ExtraIcons:OnDisable()
end
end
self._isEditModeActive = nil
+ self._mainViewerOffsetX = nil
self._trackedEquipSlots = nil
end
diff --git a/Runtime.lua b/Runtime.lua
index 20505649..6b6af7be 100644
--- a/Runtime.lua
+++ b/Runtime.lua
@@ -417,6 +417,14 @@ function Runtime.SetLayoutPreview(active)
Runtime.ScheduleLayoutUpdate(0, active and "LayoutPreviewOn" or "LayoutPreviewOff")
end
+--- Returns the current alpha chosen by Runtime's fade logic.
+--- External viewers that are not enforced directly by Runtime can use this
+--- to restore themselves without hardcoding full opacity.
+---@return number
+function Runtime.GetDesiredAlpha()
+ return _desiredAlpha
+end
+
--- Shared layout execution: hooks deferred frames, updates visibility, runs layout.
local function executeLayout(reason)
_layoutPending = false
diff --git a/SpellColors.lua b/SpellColors.lua
index 84b0b9b1..9abe1c2f 100644
--- a/SpellColors.lua
+++ b/SpellColors.lua
@@ -9,9 +9,21 @@
local _, ns = ...
+local C = ns.Constants
+
+---@class ECM_SpellColorStore
+---@field _scope string
+---@field _configAccessor (fun(): table|nil)|nil
+---@field _discoveredKeys ECM_SpellColorKey[]
+---@field _SetConfigAccessor fun(self: ECM_SpellColorStore, accessor: fun(): table|nil)
+
local SpellColors = {}
ns.SpellColors = SpellColors
+local SpellColorStore = {}
+SpellColorStore.__index = SpellColorStore
local FrameUtil = ns.FrameUtil
+local DEFAULT_SCOPE = C.SCOPE_BUFFBARS
+local _storesByScope = {}
local KEY_DEFS = { "byName", "bySpellID", "byCooldownID", "byTexture" }
local KEY_TYPE_TO_STORE = {
@@ -27,6 +39,10 @@ local KEY_TYPES = {
textureFileID = true,
}
+local function normalizeScope(scope)
+ return type(scope) == "string" and scope or DEFAULT_SCOPE
+end
+
---------------------------------------------------------------------------
-- Key validation
---------------------------------------------------------------------------
@@ -44,7 +60,7 @@ end
-- SpellColorKeyType class
---------------------------------------------------------------------------
----@class ECM_SpellColorKeyType
+---@class ECM_SpellColorKeyType : ECM_SpellColorKey
local SpellColorKeyType = {}
SpellColorKeyType.__index = SpellColorKeyType
@@ -272,7 +288,7 @@ function SpellColors.MergeKeys(base, other)
end
-- WoW uses Lua 5.1 (global `unpack`), busted tests use Lua 5.3+ (`table.unpack`).
-local unpack = unpack or table.unpack
+local unpack = _G.unpack or table.unpack
---------------------------------------------------------------------------
-- Entry metadata helpers
@@ -408,14 +424,32 @@ end
--- Runtime cache of keys discovered from active bars during layout.
--- Merged into GetAllColorEntries so the options UI sees all visible bars
--- without reaching into BuffBars directly.
-local _discoveredKeys = {}
+---@param store ECM_SpellColorStore
+---@return ECM_SpellColorKey[]
+local function getDiscoveredKeys(store)
+ local discoveredKeys = store._discoveredKeys
+ if not discoveredKeys then
+ discoveredKeys = {}
+ store._discoveredKeys = discoveredKeys
+ end
+ return discoveredKeys
+end
---------------------------------------------------------------------------
-- Profile helpers
---------------------------------------------------------------------------
+--- Returns the scope-specific default color fallback.
+---@param scope string|nil
+---@return ECM_Color
+local function getScopeDefaultColor(scope)
+ local defaults = ns.defaults and ns.defaults.profile and ns.defaults.profile[normalizeScope(scope)]
+ local color = defaults and defaults.colors and defaults.colors.defaultColor
+ return color or ns.Constants.BUFFBARS_DEFAULT_COLOR
+end
+
--- Ensures the color storage tables exist for the current class/spec.
----@param cfg table buffBars config table
+---@param cfg table scope config table
---@return table|nil classSpecStores Keyed by KEY_DEFS field names; each value is the current class/spec storage table.
local function getCurrentClassSpecStores(cfg)
local _, _, classID = UnitClass("player")
@@ -439,8 +473,9 @@ local function getCurrentClassSpecStores(cfg)
end
--- Ensures nested tables exist for color storage.
----@param cfg table buffBars config table
-local function ensureProfileIsSetup(cfg)
+---@param cfg table scope config table
+---@param scope string|nil
+local function ensureProfileIsSetup(cfg, scope)
if not cfg.colors then
cfg.colors = {
byName = {},
@@ -448,7 +483,7 @@ local function ensureProfileIsSetup(cfg)
byCooldownID = {},
byTexture = {},
cache = {},
- defaultColor = ns.Constants.BUFFBARS_DEFAULT_COLOR,
+ defaultColor = getScopeDefaultColor(scope),
}
end
for _, def in ipairs(KEY_DEFS) do
@@ -460,35 +495,79 @@ local function ensureProfileIsSetup(cfg)
cfg.colors.cache = {}
end
if type(cfg.colors.defaultColor) ~= "table" then
- cfg.colors.defaultColor = ns.Constants.BUFFBARS_DEFAULT_COLOR
+ cfg.colors.defaultColor = getScopeDefaultColor(scope)
end
end
---- Config accessor — defaults to reading from the addon's profile, but can be
---- overridden via SetConfigAccessor for testing or decoupling.
-local _configAccessor
+--- Creates a spell-colour store bound to a single scope.
+---@param scope string|nil
+---@param configAccessor (fun(): table|nil)|nil
+---@return ECM_SpellColorStore
+function SpellColors.New(scope, configAccessor)
+ return setmetatable({
+ _scope = normalizeScope(scope),
+ _configAccessor = configAccessor,
+ _discoveredKeys = {},
+ }, SpellColorStore)
+end
---- Allows callers (e.g., BuffBars module) to inject a config accessor,
---- decoupling SpellColors from direct db.profile access.
----@param accessor fun(): table|nil
-function SpellColors.SetConfigAccessor(accessor)
- _configAccessor = accessor
+--- Returns the shared spell-colour store for a scope.
+---@param scope string|nil
+---@return ECM_SpellColorStore
+function SpellColors.Get(scope)
+ local resolvedScope = normalizeScope(scope)
+ local store = _storesByScope[resolvedScope]
+ if not store then
+ store = SpellColors.New(resolvedScope)
+ _storesByScope[resolvedScope] = store
+ end
+ return store
end
---- Returns the buffBars config table, or nil if unavailable.
+-- Not used by production code; retained for tests that need to swap config sources after construction.
+function SpellColorStore:_SetConfigAccessor(accessor)
+ self._configAccessor = accessor
+end
+
+--- Returns the profile or scope config source table for a store, or nil if unavailable.
+---@param store ECM_SpellColorStore
+---@return table|nil source
+local function configSource(store)
+ local source
+ if store._configAccessor then
+ source = store._configAccessor()
+ else
+ source = ns.Addon and ns.Addon.db and ns.Addon.db.profile or nil
+ end
+ if type(source) == "table" and type(source.profile) == "table" then
+ source = source.profile
+ end
+ return source
+end
+
+--- Returns the scoped config table for a store, or nil if unavailable.
+---@param store ECM_SpellColorStore
---@return table|nil cfg
-local function config()
+local function config(store)
+ local resolvedScope = store._scope
+ local source = configSource(store)
local cfg
- if _configAccessor then
- cfg = _configAccessor()
- else
- cfg = ns.Addon and ns.Addon.db and ns.Addon.db.profile and ns.Addon.db.profile.buffBars or nil
+
+ if type(source) == "table" then
+ -- Treat the requested scope table as a valid profile signal so New(scope, accessor) works when tests seed only that scope.
+ local looksLikeProfile = type(source[resolvedScope]) == "table" or type(source[DEFAULT_SCOPE]) == "table"
+ if looksLikeProfile then
+ cfg = source[resolvedScope]
+ elseif resolvedScope == DEFAULT_SCOPE then
+ cfg = source
+ end
end
+
if type(cfg) ~= "table" then
- ns.DebugAssert(false, "SpellColors.config - missing or invalid buffBars config")
+ ns.DebugAssert(false, "SpellColors.config - missing or invalid scope config", { scope = resolvedScope })
return nil
end
- ensureProfileIsSetup(cfg)
+ ensureProfileIsSetup(cfg, resolvedScope)
return cfg
end
@@ -519,8 +598,9 @@ end
---------------------------------------------------------------------------
--- Returns the 4 tier sub-tables for the current class/spec, or nil.
-local function scopeTables()
- local cfg = config()
+---@param store ECM_SpellColorStore
+local function scopeTables(store)
+ local cfg = config(store)
if not cfg then
return nil
end
@@ -541,8 +621,9 @@ local function validateKeys(keys)
end
--- Looks up a value by trying keys in priority order (index 1 first).
-local function storeGet(keys)
- local tables = scopeTables()
+---@param store ECM_SpellColorStore
+local function storeGet(store, keys)
+ local tables = scopeTables(store)
if not tables then
return nil
end
@@ -560,8 +641,9 @@ end
--- Stores a value under all valid keys. Reuses the oldest existing stamped
--- wrapper to keep all tier references pointing to the same table.
-local function storeSet(keys, value, meta)
- local tables = scopeTables()
+---@param store ECM_SpellColorStore
+local function storeSet(store, keys, value, meta)
+ local tables = scopeTables(store)
if not tables then
return
end
@@ -598,8 +680,9 @@ local function storeSet(keys, value, meta)
end
--- Removes entries from all tier tables.
-local function storeRemove(keys)
- local tables = scopeTables()
+---@param store ECM_SpellColorStore
+local function storeRemove(store, keys)
+ local tables = scopeTables(store)
local cleared = {}
for i = 1, #KEY_DEFS do
cleared[i] = false
@@ -615,8 +698,9 @@ end
--- Reconciles a single key set: finds the most-recently-written entry
--- across all tiers and propagates it to every valid tier that is missing
--- or outdated.
-local function reconcile(keys)
- local tables = scopeTables()
+---@param store ECM_SpellColorStore
+local function reconcile(store, keys)
+ local tables = scopeTables(store)
if not tables then
return false
end
@@ -660,19 +744,21 @@ local function reconcile(keys)
end
--- Reconciles a batch of key arrays.
-local function reconcileAll(keysList)
+---@param store ECM_SpellColorStore
+local function reconcileAll(store, keysList)
local changed = 0
for _, keys in ipairs(keysList) do
- if reconcile(keys) then
+ if reconcile(store, keys) then
changed = changed + 1
end
end
return changed
end
+---@param store ECM_SpellColorStore
---@return number changed
-local function repairCurrentSpecStoreMetadata()
- local cfg = config()
+local function repairCurrentSpecStoreMetadata(store)
+ local cfg = config(store)
if not cfg then
return 0
end
@@ -733,10 +819,11 @@ local function removeMatchingStoreEntries(storeTable, tierKeyType, target)
return true
end
+---@param store ECM_SpellColorStore
---@param target ECM_SpellColorKey|nil
---@return boolean removed
-local function removeMatchingPersistedEntries(target)
- local tables = scopeTables()
+local function removeMatchingPersistedEntries(store, target)
+ local tables = scopeTables(store)
if not tables or not target then
return false
end
@@ -751,28 +838,30 @@ local function removeMatchingPersistedEntries(target)
return removed
end
+---@param store ECM_SpellColorStore
---@param target ECM_SpellColorKey|nil
---@return boolean removed
-local function removeMatchingDiscoveredEntries(target)
+local function removeMatchingDiscoveredEntries(store, target)
if not target then
return false
end
+ local discoveredKeys = getDiscoveredKeys(store)
local removed = false
local nextIndex = 1
- for index = 1, #_discoveredKeys do
- local key = _discoveredKeys[index]
+ for index = 1, #discoveredKeys do
+ local key = discoveredKeys[index]
if key and keysMatch(key, target) then
removed = true
else
- _discoveredKeys[nextIndex] = key
+ discoveredKeys[nextIndex] = key
nextIndex = nextIndex + 1
end
end
- for index = nextIndex, #_discoveredKeys do
- _discoveredKeys[index] = nil
+ for index = nextIndex, #discoveredKeys do
+ discoveredKeys[index] = nil
end
return removed
@@ -785,12 +874,12 @@ end
--- Gets the custom color for a spell by a normalized key object.
---@param key ECM_SpellColorKey|table|nil
---@return ECM_Color|nil
-function SpellColors.GetColorByKey(key)
+function SpellColorStore:GetColorByKey(key)
local normalized = normalizeKey(key)
if not normalized then
return nil
end
- return storeGet(normalized:ToArray())
+ return storeGet(self, normalized:ToArray())
end
--- Extracts identifying values from a bar frame and returns a normalized key.
@@ -808,7 +897,7 @@ end
--- Gets the custom color for a bar frame.
---@param frame ECM_BuffBarMixin
---@return ECM_Color|nil
-function SpellColors.GetColorForBar(frame)
+function SpellColorStore:GetColorForBar(frame)
ns.DebugAssert(frame, "Expected bar frame")
if not (frame and frame.__ecmHooked) then
@@ -820,13 +909,13 @@ function SpellColors.GetColorForBar(frame)
return nil
end
- return SpellColors.GetColorByKey(makeKeyFromBar(frame))
+ return self:GetColorByKey(makeKeyFromBar(frame))
end
--- Returns deduplicated color entries for the current class/spec.
---@return { key: ECM_SpellColorKey, color: ECM_Color }[]
-function SpellColors.GetAllColorEntries()
- local cfg = config()
+function SpellColorStore:GetAllColorEntries()
+ local cfg = config(self)
if not cfg then
return {}
end
@@ -895,7 +984,7 @@ function SpellColors.GetAllColorEntries()
-- Merge runtime-discovered keys so the UI shows all visible bars
-- without BuffBarsOptions reaching into BuffBars directly.
- for _, dKey in ipairs(_discoveredKeys) do
+ for _, dKey in ipairs(getDiscoveredKeys(self)) do
local merged = false
for _, row in ipairs(result) do
if row.key:Matches(dKey) then
@@ -920,7 +1009,7 @@ end
--- Sets a custom color for a spell by normalized key object.
---@param key ECM_SpellColorKey|table|nil
---@param color ECM_Color
-function SpellColors.SetColorByKey(key, color)
+function SpellColorStore:SetColorByKey(key, color)
ns.DebugAssert(type(color) == "table", "Expected color to be a table")
local normalized = normalizeKey(key)
@@ -929,23 +1018,23 @@ function SpellColors.SetColorByKey(key, color)
end
local storedColor = hasLegacyColorMetadata(color) and sanitizeColorValue(color) or color
- storeSet(normalized:ToArray(), storedColor, buildEntryMeta(normalized))
+ storeSet(self, normalized:ToArray(), storedColor, buildEntryMeta(normalized))
end
--- Returns the default bar color.
---@return ECM_Color
-function SpellColors.GetDefaultColor()
- local cfg = config()
+function SpellColorStore:GetDefaultColor()
+ local cfg = config(self)
if not cfg then
- return ns.Constants.BUFFBARS_DEFAULT_COLOR
+ return getScopeDefaultColor(self._scope)
end
return cfg.colors.defaultColor
end
--- Sets the default bar color.
---@param color ECM_Color
-function SpellColors.SetDefaultColor(color)
- local cfg = config()
+function SpellColorStore:SetDefaultColor(color)
+ local cfg = config(self)
if not cfg then
return
end
@@ -958,18 +1047,18 @@ end
---@return boolean spellIDCleared
---@return boolean cooldownIDCleared
---@return boolean textureCleared
-function SpellColors.ResetColorByKey(key)
+function SpellColorStore:ResetColorByKey(key)
local normalized = normalizeKey(key)
if not normalized then
return false, false, false, false
end
- return storeRemove(normalized:ToArray())
+ return storeRemove(self, normalized:ToArray())
end
--- Reconciles color entries for a list of normalized keys and repairs metadata.
---@param keys ECM_SpellColorKey[]|nil
---@return number changed
-function SpellColors.ReconcileAllKeys(keys)
+function SpellColorStore:ReconcileAllKeys(keys)
local keys_list = {}
if type(keys) == "table" then
for _, key in ipairs(keys) do
@@ -982,15 +1071,15 @@ function SpellColors.ReconcileAllKeys(keys)
local changed = 0
if #keys_list > 0 then
- changed = reconcileAll(keys_list)
+ changed = reconcileAll(self, keys_list)
end
- return changed + repairCurrentSpecStoreMetadata()
+ return changed + repairCurrentSpecStoreMetadata(self)
end
--- Removes persisted and discovered entries matching the given keys.
---@param keys (ECM_SpellColorKey|table)[]
---@return ECM_SpellColorKey[] removedKeys
-function SpellColors.RemoveEntriesByKeys(keys)
+function SpellColorStore:RemoveEntriesByKeys(keys)
local removedKeys = {}
if type(keys) ~= "table" then
@@ -1000,8 +1089,8 @@ function SpellColors.RemoveEntriesByKeys(keys)
for _, key in ipairs(keys) do
local normalized = normalizeKey(key)
if normalized then
- local removedPersisted = removeMatchingPersistedEntries(normalized)
- local removedDiscovered = removeMatchingDiscoveredEntries(normalized)
+ local removedPersisted = removeMatchingPersistedEntries(self, normalized)
+ local removedDiscovered = removeMatchingDiscoveredEntries(self, normalized)
if removedPersisted or removedDiscovered then
removedKeys[#removedKeys + 1] = normalized
end
@@ -1014,29 +1103,30 @@ end
--- Registers a bar frame's identifying values in the runtime discovered cache.
--- Called during layout so values are captured before they become secret.
---@param frame ECM_BuffBarMixin
-function SpellColors.DiscoverBar(frame)
+function SpellColorStore:DiscoverBar(frame)
local key = makeKeyFromBar(frame)
if not key then
return
end
- for i, existing in ipairs(_discoveredKeys) do
+ local discoveredKeys = getDiscoveredKeys(self)
+ for i, existing in ipairs(discoveredKeys) do
if keysMatch(existing, key) then
- _discoveredKeys[i] = mergeKeys(existing, key) or existing
+ discoveredKeys[i] = mergeKeys(existing, key) or existing
return
end
end
- _discoveredKeys[#_discoveredKeys + 1] = key
+ discoveredKeys[#discoveredKeys + 1] = key
end
--- Wipes the runtime discovered keys cache.
-function SpellColors.ClearDiscoveredKeys()
- wipe(_discoveredKeys)
+function SpellColorStore:ClearDiscoveredKeys()
+ wipe(getDiscoveredKeys(self))
end
--- Wipes all persisted spell color entries for the current class/spec.
---@return number cleared Total entries removed across all tiers.
-function SpellColors.ClearCurrentSpecColors()
- local cfg = config()
+function SpellColorStore:ClearCurrentSpecColors()
+ local cfg = config(self)
if not cfg then
return 0
end
diff --git a/Tests/BarMixin_spec.lua b/Tests/BarMixin_spec.lua
index f7fbb9a7..ad60ec0b 100644
--- a/Tests/BarMixin_spec.lua
+++ b/Tests/BarMixin_spec.lua
@@ -198,6 +198,18 @@ describe("BarMixin", function()
end)
end)
+ it("exports shared child-bar styling helpers", function()
+ TestHelpers.LoadChunk("BarStyle.lua", "Unable to load BarStyle.lua")(nil, ns)
+ local BarStyle = assert(ns.BarStyle, "BarStyle module did not initialize")
+ assert.is_function(BarStyle.ApplySquareIconStyle)
+ assert.is_function(BarStyle.StyleBarHeight)
+ assert.is_function(BarStyle.StyleBarBackground)
+ assert.is_function(BarStyle.StyleBarColor)
+ assert.is_function(BarStyle.StyleBarIcon)
+ assert.is_function(BarStyle.StyleBarAnchors)
+ assert.is_function(BarStyle.StyleChildBar)
+ end)
+
describe("tick helpers", function()
local function makeTick()
return {
diff --git a/Tests/ECM_Runtime_spec.lua b/Tests/ECM_Runtime_spec.lua
index 4f24aa64..432cfab5 100644
--- a/Tests/ECM_Runtime_spec.lua
+++ b/Tests/ECM_Runtime_spec.lua
@@ -382,6 +382,15 @@ describe("ECM.Runtime layout system", function()
assert.are.equal(0.5, mod.InnerFrame:GetAlpha())
end)
+ it("reports the current desired alpha for external viewer restores", function()
+ makeRegisteredModule()
+ _G._testDB.profile.global.outOfCombatFade = makeFadeConfig(50)
+
+ ns.Runtime.ScheduleLayoutUpdate(0, "fade")
+
+ assert.are.equal(0.5, ns.Runtime.GetDesiredAlpha())
+ end)
+
it("does not fade in a delve when instance exceptions are enabled", function()
local mod = makeRegisteredModule()
local fadeConfig = makeFadeConfig(50)
diff --git a/Tests/Modules/BuffBars_spec.lua b/Tests/Modules/BuffBars_spec.lua
index 1213684e..fd0f67af 100644
--- a/Tests/Modules/BuffBars_spec.lua
+++ b/Tests/Modules/BuffBars_spec.lua
@@ -11,9 +11,12 @@ describe("BuffBars real source", function()
local BuffBarCooldownViewer
local ns
local makeFrame = TestHelpers.makeFrame
+ local makeTexture = TestHelpers.makeTexture
local registerFrameCalls
local unregisterFrameCalls
local addMixinCalls
+ local spellColorStore
+ local spellColorGetScopes
local timerCallbacks
setup(function()
@@ -24,6 +27,9 @@ describe("BuffBars real source", function()
"InCombatLockdown",
"issecretvalue",
"C_Timer",
+ "GetTime",
+ "CreateFrame",
+ "LibStub",
})
end)
@@ -34,10 +40,10 @@ describe("BuffBars real source", function()
local makeHookableFrame = TestHelpers.makeHookableFrame
local function stubChildLayoutEnvironment()
- ns.SpellColors.GetColorForBar = function()
+ spellColorStore.GetColorForBar = function(_, _)
return { r = 0.4, g = 0.5, b = 0.6, a = 1 }
end
- ns.SpellColors.GetDefaultColor = function()
+ spellColorStore.GetDefaultColor = function(_)
return { r = 1, g = 1, b = 1, a = 1 }
end
ns.ColorUtil = {
@@ -127,7 +133,18 @@ describe("BuffBars real source", function()
registerFrameCalls = 0
unregisterFrameCalls = 0
addMixinCalls = 0
+ spellColorGetScopes = {}
timerCallbacks = {}
+ spellColorStore = {
+ GetColorForBar = function()
+ return nil
+ end,
+ GetDefaultColor = function()
+ return { r = 1, g = 1, b = 1, a = 1 }
+ end,
+ ClearDiscoveredKeys = function() end,
+ DiscoverBar = function() end,
+ }
ns = {
Log = function() end,
DebugAssert = function() end,
@@ -207,11 +224,13 @@ describe("BuffBars real source", function()
textureFileID = textureFileID,
}
end,
- SetConfigAccessor = function() end,
- ClearDiscoveredKeys = function() end,
- DiscoverBar = function() end,
+ Get = function(scope)
+ spellColorGetScopes[#spellColorGetScopes + 1] = scope
+ return spellColorStore
+ end,
},
Runtime = {
+ ScheduleLayoutUpdate = function() end,
RegisterFrame = function()
registerFrameCalls = registerFrameCalls + 1
end,
@@ -224,6 +243,67 @@ describe("BuffBars real source", function()
TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
+ _G.GetTime = function()
+ return 0
+ end
+ _G.CreateFrame = function(_, name)
+ local frame = makeFrame({ name = name })
+ frame.CreateTexture = function()
+ return makeTexture()
+ end
+ frame.CreateFontString = function()
+ local fs = makeTexture()
+ fs.SetText = function() end
+ fs.SetJustifyH = function() end
+ fs.SetJustifyV = function() end
+ fs.SetPoint = function() end
+ return fs
+ end
+ frame.SetFrameStrata = function() end
+ frame.SetFrameLevel = function() end
+ frame.GetFrameLevel = function()
+ return 1
+ end
+ frame.SetAllPoints = function() end
+ frame.SetMinMaxValues = function() end
+ frame.SetValue = function() end
+ frame.SetStatusBarTexture = function() end
+ frame.SetStatusBarColor = function() end
+ return frame
+ end
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupLibEditModeStub()
+ TestHelpers.LoadChunk("BarMixin.lua", "Unable to load BarMixin.lua")(nil, ns)
+ TestHelpers.LoadChunk("BarStyle.lua", "Unable to load BarStyle.lua")(nil, ns)
+ assert(ns.BarMixin, "BarMixin module did not initialize")
+ assert(ns.BarStyle, "BarStyle module did not initialize")
+ ns.BarMixin = {
+ FrameProto = {
+ ChainRightPoint = function(point, fallback)
+ if point == "TOPLEFT" then
+ return "TOPRIGHT"
+ end
+ if point == "BOTTOMLEFT" then
+ return "BOTTOMRIGHT"
+ end
+ return fallback
+ end,
+ NormalizeGrowDirection = function(direction)
+ return direction
+ end,
+ CalculateLayoutParams = function()
+ return {}
+ end,
+ IsReady = function()
+ return true
+ end,
+ },
+ AddFrameMixin = function(target)
+ addMixinCalls = addMixinCalls + 1
+ target.EnsureFrame = target.EnsureFrame or function() end
+ end,
+ }
+
_G.UIParent = makeFrame({ name = "UIParent", width = 1920, height = 1080 })
_G.hooksecurefunc = function(object, methodName, callback)
local original = object[methodName]
@@ -660,11 +740,11 @@ describe("BuffBars real source", function()
return false
end
local secretColorRequested = false
- ns.SpellColors.GetColorForBar = function()
+ spellColorStore.GetColorForBar = function()
secretColorRequested = true
return nil
end
- ns.SpellColors.GetDefaultColor = function()
+ spellColorStore.GetDefaultColor = function()
return { r = 1, g = 1, b = 1, a = 1 }
end
ns.ColorUtil = {
@@ -768,16 +848,17 @@ describe("BuffBars real source", function()
local cleared = 0
local appliedTextures = {}
local appliedColors = {}
- ns.SpellColors.ClearDiscoveredKeys = function()
+ spellColorGetScopes = {}
+ spellColorStore.ClearDiscoveredKeys = function()
cleared = cleared + 1
end
- ns.SpellColors.DiscoverBar = function(frame)
+ spellColorStore.DiscoverBar = function(_, frame)
discovered[#discovered + 1] = frame
end
- ns.SpellColors.GetColorForBar = function()
+ spellColorStore.GetColorForBar = function()
return { r = 0.4, g = 0.5, b = 0.6, a = 1 }
end
- ns.SpellColors.GetDefaultColor = function()
+ spellColorStore.GetDefaultColor = function()
return { r = 1, g = 1, b = 1, a = 1 }
end
ns.ColorUtil = {
@@ -849,6 +930,10 @@ describe("BuffBars real source", function()
assert.is_true(result)
assert.are.equal(1, cleared)
assert.same({ second, first }, discovered)
+ assert.is_true(#spellColorGetScopes > 0)
+ for _, scope in ipairs(spellColorGetScopes) do
+ assert.are.equal(ns.Constants.SCOPE_BUFFBARS, scope)
+ end
assert.is_true(first.__ecmHooked)
assert.is_true(second.__ecmHooked)
assert.are.equal(2, #appliedTextures)
@@ -857,11 +942,11 @@ describe("BuffBars real source", function()
end)
it("clears _layoutRunning even when styleChildFrame throws", function()
- ns.SpellColors.DiscoverBar = function() end
- ns.SpellColors.GetColorForBar = function()
+ spellColorStore.DiscoverBar = function() end
+ spellColorStore.GetColorForBar = function()
error("simulated style error")
end
- ns.SpellColors.GetDefaultColor = function()
+ spellColorStore.GetDefaultColor = function()
return { r = 1, g = 1, b = 1, a = 1 }
end
ns.ColorUtil = { ColorToHex = function() return "ffffff" end }
@@ -909,7 +994,7 @@ describe("BuffBars real source", function()
-- Helper: runs a single child through UpdateLayout and returns it styled.
local function layoutSingleChild(child, moduleConfig, globalConfig)
stubChildLayoutEnvironment()
- ns.SpellColors.DiscoverBar = function() end
+ spellColorStore.DiscoverBar = function() end
function BuffBarCooldownViewer:GetChildren() return child end
function BuffBars:ShouldShow() return true end
function BuffBars:GetGlobalConfig() return globalConfig end
@@ -966,7 +1051,7 @@ describe("BuffBars real source", function()
local child = makeStyledChild("BG", true, 1)
local bgColor = { r = 0.1, g = 0.2, b = 0.3, a = 0.5 }
stubChildLayoutEnvironment()
- ns.SpellColors.DiscoverBar = function() end
+ spellColorStore.DiscoverBar = function() end
ns.FrameUtil.GetBarBackground = function() return bgRegion end
function BuffBarCooldownViewer:GetChildren() return child end
function BuffBars:ShouldShow() return true end
@@ -985,7 +1070,11 @@ describe("BuffBars real source", function()
local appliedColor
local child = makeStyledChild("C", true, 1)
stubChildLayoutEnvironment()
- ns.SpellColors.DiscoverBar = function() end
+ spellColorGetScopes = {}
+ spellColorStore.DiscoverBar = function() end
+ spellColorStore.GetColorForBar = function()
+ return { r = 0.4, g = 0.5, b = 0.6, a = 1 }
+ end
ns.FrameUtil.LazySetStatusBarColor = function(_, r, g, b, a)
appliedColor = { r, g, b, a }
end
@@ -997,6 +1086,10 @@ describe("BuffBars real source", function()
assert.is_not_nil(appliedColor)
assert.same({ 0.4, 0.5, 0.6, 1.0 }, appliedColor)
+ assert.is_true(#spellColorGetScopes > 0)
+ for _, scope in ipairs(spellColorGetScopes) do
+ assert.are.equal(ns.Constants.SCOPE_BUFFBARS, scope)
+ end
end)
it("schedules retry when all identifiers are secret", function()
diff --git a/Tests/Modules/ExternalBars_spec.lua b/Tests/Modules/ExternalBars_spec.lua
new file mode 100644
index 00000000..da3f0e7c
--- /dev/null
+++ b/Tests/Modules/ExternalBars_spec.lua
@@ -0,0 +1,768 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers = assert(
+ loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"),
+ "Unable to load Tests/TestHelpers.lua"
+)()
+
+describe("ExternalBars real source", function()
+ local originalGlobals
+ local ExternalBars
+ local ns
+ local profile
+ local viewer
+ local fakeTime
+ local afterCallbacks
+ local retryTimers
+ local durationTickers
+ local requestLayoutReasons
+ local registerFrameCalls
+ local unregisterFrameCalls
+ local auraDataByInstanceID
+ local colorLookupScopes
+ local discoveredScopes
+ local spellColorStores
+ local runtimeAlpha
+
+ local makeFrame = TestHelpers.makeFrame
+ local makeHookableFrame = TestHelpers.makeHookableFrame
+ local makeTexture = TestHelpers.makeTexture
+ local makeStatusBar = TestHelpers.makeStatusBar
+
+ local function makeFontString()
+ local fontString = makeFrame({ shown = true })
+ fontString.__text = nil
+
+ function fontString:SetText(text)
+ self.__text = text
+ end
+
+ function fontString:GetText()
+ return self.__text
+ end
+
+ function fontString:SetWordWrap() end
+ function fontString:SetJustifyH() end
+ function fontString:SetJustifyV() end
+ function fontString:SetFontObject() end
+ function fontString:SetShown(shown)
+ if shown then
+ self:Show()
+ else
+ self:Hide()
+ end
+ end
+
+ return fontString
+ end
+
+ local function makeTextureRegion()
+ local texture = makeTexture()
+ local originalSetTexture = texture.SetTexture
+ texture.__shown = true
+ texture.__alpha = 1
+
+ function texture:SetTexture(value)
+ originalSetTexture(self, value)
+ if type(value) == "number" then
+ self.__textureFileID = value
+ end
+ end
+
+ function texture:SetShown(shown)
+ self.__shown = not not shown
+ end
+
+ function texture:Show()
+ self.__shown = true
+ end
+
+ function texture:Hide()
+ self.__shown = false
+ end
+
+ function texture:IsShown()
+ return self.__shown
+ end
+
+ function texture:SetAllPoints() end
+ function texture:ClearAllPoints() end
+ function texture:SetPoint() end
+ function texture:SetTexCoord(...)
+ self.__texCoord = { ... }
+ end
+ function texture:SetAtlas(atlas)
+ self.__atlas = atlas
+ end
+ function texture:SetParent(parent)
+ self.__parent = parent
+ end
+ function texture:SetDrawLayer(layer, subLayer)
+ self.__drawLayer = { layer, subLayer }
+ end
+ function texture:SetAlpha(alpha)
+ self.__alpha = alpha
+ end
+ function texture:GetAlpha()
+ return self.__alpha
+ end
+
+ return texture
+ end
+
+ local function addFrameFeatures(frame, parent)
+ frame.__frameLevel = frame.__frameLevel
+ or (parent and parent.GetFrameLevel and parent:GetFrameLevel() + 1)
+ or 0
+ frame.__mouseEnabled = frame.__mouseEnabled ~= false
+
+ if not frame.SetFrameLevel then
+ function frame:SetFrameLevel(level)
+ self.__frameLevel = level
+ end
+ end
+
+ if not frame.GetFrameLevel then
+ function frame:GetFrameLevel()
+ return self.__frameLevel or 0
+ end
+ end
+
+ if not frame.SetShown then
+ function frame:SetShown(shown)
+ if shown then
+ self:Show()
+ else
+ self:Hide()
+ end
+ end
+ end
+
+ if not frame.EnableMouse then
+ function frame:EnableMouse(enabled)
+ self.__mouseEnabled = not not enabled
+ end
+ end
+
+ if not frame.IsMouseEnabled then
+ function frame:IsMouseEnabled()
+ return self.__mouseEnabled
+ end
+ end
+
+ function frame:SetParent(newParent)
+ self.__parent = newParent
+ end
+
+ function frame:CreateTexture()
+ local texture = makeTextureRegion()
+ self.__textures = self.__textures or {}
+ self.__textures[#self.__textures + 1] = texture
+ return texture
+ end
+
+ function frame:CreateFontString()
+ local fontString = makeFontString()
+ self.__fontStrings = self.__fontStrings or {}
+ self.__fontStrings[#self.__fontStrings + 1] = fontString
+ return fontString
+ end
+
+ return frame
+ end
+
+ local function makeStatusBarFrame(parent)
+ local bar = addFrameFeatures(makeStatusBar(), parent)
+
+ function bar:SetMinMaxValues(minValue, maxValue)
+ self.__minValue = minValue
+ self.__maxValue = maxValue
+ end
+
+ function bar:SetValue(value)
+ self.__value = value
+ end
+
+ function bar:GetValue()
+ return self.__value
+ end
+
+ return bar
+ end
+
+ local function makeCooldownFrame(parent)
+ local cooldown = addFrameFeatures(makeFrame({ shown = true }), parent)
+ cooldown.__setCooldownDurationCalls = {}
+ cooldown.__clearCalls = 0
+
+ function cooldown:SetDrawSwipe(value)
+ self.__drawSwipe = value
+ end
+
+ function cooldown:SetDrawEdge(value)
+ self.__drawEdge = value
+ end
+
+ function cooldown:SetDrawBling(value)
+ self.__drawBling = value
+ end
+
+ function cooldown:SetReverse(value)
+ self.__reverse = value
+ end
+
+ function cooldown:SetHideCountdownNumbers(value)
+ self.__hideCountdownNumbers = value
+ end
+
+ function cooldown:SetSwipeColor(r, g, b, a)
+ self.__swipeColor = { r, g, b, a }
+ end
+
+ function cooldown:SetCooldownDuration(duration, timeMod)
+ self.__lastDuration = duration
+ self.__lastTimeMod = timeMod
+ self.__setCooldownDurationCalls[#self.__setCooldownDurationCalls + 1] = {
+ duration = duration,
+ timeMod = timeMod,
+ }
+ end
+
+ function cooldown:Clear()
+ self.__clearCalls = self.__clearCalls + 1
+ self.__lastDuration = nil
+ self.__lastTimeMod = nil
+ end
+
+ return cooldown
+ end
+
+ local function createFrameStub(frameType, _, parent)
+ if frameType == "StatusBar" then
+ return makeStatusBarFrame(parent)
+ end
+
+ if frameType == "Cooldown" then
+ return makeCooldownFrame(parent)
+ end
+
+ return addFrameFeatures(makeFrame({ shown = true }), parent)
+ end
+
+ local function setViewerAuras(auraDefs)
+ viewer.auraInfo = {}
+ viewer.auraFrames = {}
+ auraDataByInstanceID = {}
+
+ for index, aura in ipairs(auraDefs or {}) do
+ viewer.auraInfo[index] = {
+ auraInstanceID = aura.auraInstanceID,
+ texture = aura.texture,
+ duration = aura.duration,
+ expirationTime = aura.expirationTime,
+ timeMod = aura.timeMod,
+ }
+ viewer.auraFrames[index] = {
+ auraInstanceID = aura.auraInstanceID,
+ icon = aura.texture,
+ }
+ auraDataByInstanceID[aura.auraInstanceID] = aura.auraData
+ end
+ end
+
+ local function ensureModuleFrame()
+ if not ExternalBars.InnerFrame then
+ ExternalBars:EnsureFrame()
+ end
+ end
+
+ local function syncAndLayout(reason)
+ ensureModuleFrame()
+ ExternalBars:OnExternalAurasUpdated()
+ return ExternalBars:UpdateLayout(reason or "test")
+ end
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals({
+ "UIParent",
+ "ExternalDefensivesFrame",
+ "C_UnitAuras",
+ "hooksecurefunc",
+ "InCombatLockdown",
+ "issecretvalue",
+ "canaccesstable",
+ "C_Timer",
+ "GetTime",
+ "CreateFrame",
+ "LibStub",
+ "SecondsToTimeAbbrev",
+ "wipe",
+ })
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ fakeTime = 100
+ afterCallbacks = {}
+ retryTimers = {}
+ durationTickers = {}
+ requestLayoutReasons = {}
+ registerFrameCalls = 0
+ unregisterFrameCalls = 0
+ auraDataByInstanceID = {}
+ colorLookupScopes = {}
+ discoveredScopes = {}
+ spellColorStores = {}
+ runtimeAlpha = 1
+
+ ns = {
+ Log = function() end,
+ DebugAssert = function() end,
+ IsDebugEnabled = function()
+ return false
+ end,
+ ToString = tostring,
+ Runtime = {
+ RegisterFrame = function()
+ registerFrameCalls = registerFrameCalls + 1
+ end,
+ UnregisterFrame = function()
+ unregisterFrameCalls = unregisterFrameCalls + 1
+ end,
+ GetDesiredAlpha = function()
+ return runtimeAlpha
+ end,
+ RequestLayout = function(reason)
+ requestLayoutReasons[#requestLayoutReasons + 1] = reason
+ end,
+ },
+ FrameUtil = {
+ GetTexture = function()
+ return "Interface\\TargetingFrame\\UI-StatusBar"
+ end,
+ ApplyFont = function() end,
+ GetBarBackground = function(bar)
+ return bar and bar.__textures and bar.__textures[1] or nil
+ end,
+ GetIconTexture = function(frame)
+ return frame and frame._iconTexture or nil
+ end,
+ GetIconOverlay = function()
+ return nil
+ end,
+ GetIconTextureFileID = function(frame)
+ local iconTexture = frame and frame._iconTexture
+ return iconTexture and iconTexture:GetTextureFileID() or nil
+ end,
+ LazySetHeight = function(frame, value)
+ frame:SetHeight(value)
+ end,
+ LazySetWidth = function(frame, value)
+ frame:SetWidth(value)
+ end,
+ LazySetAnchors = function(frame, anchors)
+ frame.__ecmAnchorCache = anchors
+ frame.__anchors = anchors
+ end,
+ LazySetStatusBarTexture = function(bar, texture)
+ bar:SetStatusBarTexture(texture)
+ end,
+ LazySetStatusBarColor = function(bar, r, g, b, a)
+ bar:SetStatusBarColor(r, g, b, a)
+ end,
+ LazySetAlpha = function(frame, alpha)
+ frame:SetAlpha(alpha)
+ end,
+ },
+ SpellColors = {
+ Get = function(scope)
+ local storeKey = scope or false
+ local store = spellColorStores[storeKey]
+ if store then
+ return store
+ end
+
+ store = {
+ GetColorForBar = function()
+ colorLookupScopes[#colorLookupScopes + 1] = scope
+ return nil
+ end,
+ GetDefaultColor = function()
+ assert.are.equal(ns.Constants.SCOPE_EXTERNALBARS, scope)
+ return { r = 0.40, g = 0.78, b = 0.95, a = 1 }
+ end,
+ DiscoverBar = function()
+ discoveredScopes[#discoveredScopes + 1] = scope
+ end,
+ ClearDiscoveredKeys = function() end,
+ }
+ spellColorStores[storeKey] = store
+ return store
+ end,
+ MakeKey = function(name, spellID, cooldownID, textureFileID)
+ if not name and not spellID and not cooldownID and not textureFileID then
+ return nil
+ end
+
+ return {
+ spellName = name,
+ spellID = spellID,
+ cooldownID = cooldownID,
+ textureFileID = textureFileID,
+ }
+ end,
+ },
+ }
+
+ TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
+ TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
+
+ profile = {
+ global = {
+ barHeight = 18,
+ barWidth = 240,
+ texture = "Solid",
+ barBgColor = { r = 0.1, g = 0.1, b = 0.1, a = 0.8 },
+ offsetY = 0,
+ moduleSpacing = 2,
+ detachedModuleSpacing = 2,
+ moduleGrowDirection = ns.Constants.GROW_DIRECTION_DOWN,
+ detachedGrowDirection = ns.Constants.GROW_DIRECTION_DOWN,
+ },
+ externalBars = {
+ enabled = true,
+ hideOriginalIcons = false,
+ showIcon = true,
+ showSpellName = true,
+ showDuration = true,
+ anchorMode = ns.Constants.ANCHORMODE_CHAIN,
+ height = 0,
+ verticalSpacing = 0,
+ },
+ }
+
+ ns.GetGlobalConfig = function()
+ return profile.global
+ end
+
+ ns.Addon = {
+ db = { profile = profile },
+ GetECMModule = function(addon, name)
+ return rawget(addon, name)
+ end,
+ NewModule = function(addon, name)
+ local module = {
+ Name = name,
+ _enabled = true,
+ }
+
+ function module:IsEnabled()
+ return self._enabled
+ end
+
+ function module:RegisterEvent(eventName)
+ self._registeredEvents = self._registeredEvents or {}
+ self._registeredEvents[eventName] = true
+ end
+
+ function module:UnregisterAllEvents()
+ self._unregisteredAllEvents = true
+ end
+
+ addon[name] = module
+ return module
+ end,
+ }
+
+ _G.GetTime = function()
+ return fakeTime
+ end
+ _G.InCombatLockdown = function()
+ return false
+ end
+ _G.issecretvalue = function()
+ return false
+ end
+ _G.canaccesstable = function(value)
+ return type(value) == "table"
+ end
+ _G.C_UnitAuras = {
+ GetAuraDataByAuraInstanceID = function(_, auraInstanceID)
+ return auraDataByInstanceID[auraInstanceID]
+ end,
+ }
+ _G.wipe = function(tbl)
+ for key in pairs(tbl) do
+ tbl[key] = nil
+ end
+ end
+ _G.SecondsToTimeAbbrev = nil
+ _G.C_Timer = {
+ After = function(_, callback)
+ afterCallbacks[#afterCallbacks + 1] = callback
+ end,
+ NewTimer = function(_, callback)
+ local timer = { cancelled = false, callback = callback }
+
+ function timer:Cancel()
+ self.cancelled = true
+ end
+
+ retryTimers[#retryTimers + 1] = timer
+ return timer
+ end,
+ NewTicker = function(_, callback)
+ local ticker = { cancelled = false, callback = callback }
+
+ function ticker:Cancel()
+ self.cancelled = true
+ end
+
+ durationTickers[#durationTickers + 1] = ticker
+ return ticker
+ end,
+ }
+
+ _G.UIParent = addFrameFeatures(makeFrame({ name = "UIParent", shown = true, width = 1920, height = 1080 }))
+ _G.CreateFrame = function(frameType, name, parent)
+ return createFrameStub(frameType, name, parent)
+ end
+ _G.hooksecurefunc = function(object, methodName, callback)
+ local original = object[methodName]
+ object[methodName] = function(self, ...)
+ if original then
+ original(self, ...)
+ end
+ callback(self, ...)
+ end
+ end
+
+ viewer = addFrameFeatures(makeHookableFrame({ name = "ExternalDefensivesFrame", shown = true }))
+ viewer.auraInfo = {}
+ viewer.auraFrames = {}
+
+ function viewer:UpdateAuras()
+ self._updateAuraCalls = (self._updateAuraCalls or 0) + 1
+ end
+
+ _G.ExternalDefensivesFrame = viewer
+
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupLibEditModeStub()
+ TestHelpers.LoadChunk("BarMixin.lua", "Unable to load BarMixin.lua")(nil, ns)
+ TestHelpers.LoadChunk("BarStyle.lua", "Unable to load BarStyle.lua")(nil, ns)
+ TestHelpers.LoadChunk("Modules/ExternalBars.lua", "Unable to load Modules/ExternalBars.lua")(nil, ns)
+
+ ExternalBars = assert(ns.Addon.ExternalBars, "ExternalBars module did not initialize")
+ ExternalBars:OnInitialize()
+ end)
+
+ it("creates bars from external aura updates and configures cooldown duration", function()
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1.5,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("test-normal"))
+
+ local bar = assert(ExternalBars._barPool[1])
+ assert.is_true(bar:IsShown())
+ assert.are.equal(5011, bar._iconTexture:GetTexture())
+ assert.are.equal("Ironbark", bar.Bar.Name:GetText())
+ assert.are.equal("12", bar.Bar.Duration:GetText())
+ assert.is_true(bar.Bar.Duration:IsShown())
+ assert.same({ duration = 12, timeMod = 1.5 }, bar.Cooldown.__setCooldownDurationCalls[1])
+ assert.same({ "ExternalBars:UpdateAuras" }, requestLayoutReasons)
+ assert.same({ ns.Constants.SCOPE_EXTERNALBARS }, colorLookupScopes)
+ assert.same({ ns.Constants.SCOPE_EXTERNALBARS }, discoveredScopes)
+ assert.same({ 0.40, 0.78, 0.95, 1.0 }, { bar.Bar:GetStatusBarColor() })
+ assert.are.equal(1, #durationTickers)
+ end)
+
+ it("hides duration text but still configures cooldown and schedules the all-secret color retry path", function()
+ _G.issecretvalue = function()
+ return true
+ end
+
+ setViewerAuras({
+ {
+ auraInstanceID = 22,
+ texture = 6022,
+ duration = "secret-duration",
+ expirationTime = "secret-expiration",
+ timeMod = "secret-mod",
+ auraData = { name = "secret-name", spellId = 987654 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("test-secret"))
+
+ local bar = assert(ExternalBars._barPool[1])
+ assert.is_true(bar:IsShown())
+ assert.is_false(bar.Bar.Duration:IsShown())
+ assert.is_nil(bar.Bar.Duration:GetText())
+ assert.same({ duration = "secret-duration", timeMod = "secret-mod" }, bar.Cooldown.__setCooldownDurationCalls[1])
+ assert.are.equal(1, #retryTimers)
+ assert.same({ true, "secrets" }, { ExternalBars:IsEditLocked() })
+ assert.are.equal(0, #durationTickers)
+ end)
+
+ it("reuses pooled bars and hides excess bars when aura count shrinks", function()
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ {
+ auraInstanceID = 22,
+ texture = 5022,
+ duration = 10,
+ expirationTime = 110,
+ timeMod = 1,
+ auraData = { name = "Pain Suppression", spellId = 33206 },
+ },
+ {
+ auraInstanceID = 33,
+ texture = 5033,
+ duration = 8,
+ expirationTime = 108,
+ timeMod = 1,
+ auraData = { name = "Blessing of Sacrifice", spellId = 6940 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("three-bars"))
+
+ local secondBar = assert(ExternalBars._barPool[2])
+ local thirdBar = assert(ExternalBars._barPool[3])
+
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("one-bar"))
+
+ assert.are.equal(secondBar, ExternalBars._barPool[2])
+ assert.are.equal(thirdBar, ExternalBars._barPool[3])
+ assert.is_false(secondBar:IsShown())
+ assert.is_false(thirdBar:IsShown())
+ assert.are.equal(3, #ExternalBars._barPool)
+ end)
+
+ it("hides the original icons on enable and restores them on disable", function()
+ profile.externalBars.hideOriginalIcons = true
+
+ ExternalBars:OnEnable()
+
+ assert.are.equal(1, registerFrameCalls)
+ assert.are.equal(1, #afterCallbacks)
+
+ afterCallbacks[1]()
+
+ assert.are.equal(0, viewer:GetAlpha())
+ assert.is_false(viewer:IsMouseEnabled())
+
+ requestLayoutReasons = {}
+
+ ExternalBars:OnDisable()
+
+ assert.are.equal(1, unregisterFrameCalls)
+ assert.are.equal(1, viewer:GetAlpha())
+ assert.is_true(viewer:IsMouseEnabled())
+ assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ end)
+
+ it("restores the viewer alpha when showing the original icons again", function()
+ local setAlphaCalls = {}
+
+ profile.externalBars.hideOriginalIcons = true
+ ExternalBars:_RefreshOriginalIconsState()
+ requestLayoutReasons = {}
+ runtimeAlpha = 0.35
+
+ local originalSetAlpha = viewer.SetAlpha
+ function viewer:SetAlpha(alpha)
+ setAlphaCalls[#setAlphaCalls + 1] = alpha
+ originalSetAlpha(self, alpha)
+ end
+
+ profile.externalBars.hideOriginalIcons = false
+ ExternalBars:_RefreshOriginalIconsState()
+
+ assert.same({ 0.35 }, setAlphaCalls)
+ assert.are.equal(0.35, viewer:GetAlpha())
+ assert.is_true(viewer:IsMouseEnabled())
+ assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ end)
+
+ it("keeps viewer mouse disabled when the runtime fade alpha is zero", function()
+ profile.externalBars.hideOriginalIcons = true
+ ExternalBars:_RefreshOriginalIconsState()
+ requestLayoutReasons = {}
+ runtimeAlpha = 0
+
+ profile.externalBars.hideOriginalIcons = false
+ ExternalBars:_RefreshOriginalIconsState()
+
+ assert.are.equal(0, viewer:GetAlpha())
+ assert.is_false(viewer:IsMouseEnabled())
+ assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ end)
+
+ it("computes container height from bar count, height, and vertical spacing", function()
+ profile.externalBars.height = 20
+ profile.externalBars.verticalSpacing = 3
+
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ {
+ auraInstanceID = 22,
+ texture = 5022,
+ duration = 10,
+ expirationTime = 110,
+ timeMod = 1,
+ auraData = { name = "Pain Suppression", spellId = 33206 },
+ },
+ {
+ auraInstanceID = 33,
+ texture = 5033,
+ duration = 8,
+ expirationTime = 108,
+ timeMod = 1,
+ auraData = { name = "Blessing of Sacrifice", spellId = 6940 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("height"))
+ assert.are.equal(66, ExternalBars.InnerFrame:GetHeight())
+ end)
+end)
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 3a897e23..c086c3ef 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -866,7 +866,6 @@ describe("ExtraIcons real source", function()
UtilityCooldownViewer.GetItemFrames = function()
return { utilityActiveFrame }
end
- UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
local mainActiveFrame = TestHelpers.makeFrame({ shown = true, width = 22, height = 22 })
mainActiveFrame.isActive = true
@@ -877,6 +876,7 @@ describe("ExtraIcons real source", function()
return { mainActiveFrame }
end
EssentialCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 100, 0)
+ UtilityCooldownViewer:SetPoint("LEFT", EssentialCooldownViewer, "RIGHT", 8, 0)
inventoryItemBySlot[13] = 101
inventoryTextureBySlot[13] = "trinket-1"
@@ -891,18 +891,26 @@ describe("ExtraIcons real source", function()
assert.is_true(ExtraIcons:UpdateLayout("utility"))
- local _, _, _, utilityBeforeX = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal(-13, utilityBeforeX)
+ local utilityBeforePoint, utilityBeforeRelativeTo, utilityBeforeRelativePoint, utilityBeforeX, utilityBeforeY = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal("LEFT", utilityBeforePoint)
+ assert.are.equal(EssentialCooldownViewer, utilityBeforeRelativeTo)
+ assert.are.equal("RIGHT", utilityBeforeRelativePoint)
+ assert.are.equal(-5, utilityBeforeX)
+ assert.are.equal(0, utilityBeforeY)
config.viewers.utility = {}
config.viewers.main = { { stackKey = "trinket1" } }
assert.is_true(ExtraIcons:UpdateLayout("main"))
- local _, _, _, utilityAfterX = UtilityCooldownViewer:GetPoint(1)
+ local utilityAfterPoint, utilityAfterRelativeTo, utilityAfterRelativePoint, utilityAfterX, utilityAfterY = UtilityCooldownViewer:GetPoint(1)
local _, _, _, mainAfterX = EssentialCooldownViewer:GetPoint(1)
- assert.are.equal(0, utilityAfterX)
+ assert.are.equal("LEFT", utilityAfterPoint)
+ assert.are.equal(EssentialCooldownViewer, utilityAfterRelativeTo)
+ assert.are.equal("RIGHT", utilityAfterRelativePoint)
+ assert.are.equal(8, utilityAfterX)
+ assert.are.equal(0, utilityAfterY)
assert.are.equal(87, mainAfterX)
assert.is_false(ExtraIcons._viewers.utility.container:IsShown())
assert.is_true(ExtraIcons._viewers.main.container:IsShown())
diff --git a/Tests/SpellColors_spec.lua b/Tests/SpellColors_spec.lua
index 01ec4503..a9f194c8 100644
--- a/Tests/SpellColors_spec.lua
+++ b/Tests/SpellColors_spec.lua
@@ -11,7 +11,10 @@ describe("SpellColors", function()
local originalGlobals
local SpellColors
+ local BuffSpellColors
+ local ExternalSpellColors
local buffBarsConfig
+ local externalBarsConfig
local currentClassID
local currentSpecID
@@ -151,10 +154,21 @@ describe("SpellColors", function()
TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
buffBarsConfig = {}
+ externalBarsConfig = {
+ colors = {
+ byName = {},
+ bySpellID = {},
+ byCooldownID = {},
+ byTexture = {},
+ cache = {},
+ defaultColor = { r = 0.40, g = 0.78, b = 0.95, a = 1 },
+ },
+ }
ns.Addon = {
db = {
profile = {
buffBars = buffBarsConfig,
+ externalBars = externalBarsConfig,
},
},
}
@@ -162,6 +176,28 @@ describe("SpellColors", function()
TestHelpers.LoadChunk("SpellColors.lua", "Unable to load SpellColors.lua")(nil, ns)
SpellColors = assert(ns.SpellColors, "SpellColors module did not initialize")
+ BuffSpellColors = SpellColors.Get(ns.Constants.SCOPE_BUFFBARS)
+ ExternalSpellColors = SpellColors.Get(ns.Constants.SCOPE_EXTERNALBARS)
+ end)
+
+ it("New creates isolated stores and _SetConfigAccessor is reserved for test use", function()
+ local firstExternalConfig = {}
+ local secondExternalConfig = {}
+ local store = SpellColors.New(ns.Constants.SCOPE_EXTERNALBARS, function()
+ return { externalBars = firstExternalConfig }
+ end)
+ local key = SpellColors.MakeKey("Private Test Store", 9876, nil, nil)
+ local storedColor = color(0.25, 0.5, 0.75)
+
+ store:SetColorByKey(key, storedColor)
+ assert.are.same(storedColor, store:GetColorByKey({ spellName = "Private Test Store" }))
+
+ store:_SetConfigAccessor(function()
+ return { externalBars = secondExternalConfig }
+ end)
+
+ assert.is_nil(store:GetColorByKey({ spellName = "Private Test Store" }))
+ assert.are.same({}, store:GetAllColorEntries())
end)
it("MakeKey returns nil when no valid key is available", function()
@@ -297,62 +333,62 @@ describe("SpellColors", function()
local c = color(0.1, 0.2, 0.3)
local key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001)
- SpellColors.SetColorByKey(key, c)
+ BuffSpellColors:SetColorByKey(key, c)
assert.are.same({ r = 0.1, g = 0.2, b = 0.3, a = 1 }, c)
- assert.are.same(c, SpellColors.GetColorByKey({ spellName = "Immolation Aura" }))
- assert.are.same(c, SpellColors.GetColorByKey({ spellID = 258920 }))
- assert.are.same(c, SpellColors.GetColorByKey({ cooldownID = 77 }))
- assert.are.same(c, SpellColors.GetColorByKey({ textureFileID = 9001 }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ spellName = "Immolation Aura" }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ spellID = 258920 }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ cooldownID = 77 }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ textureFileID = 9001 }))
end)
it("SetColorByKey accepts normalized keyType and primaryKey payloads", function()
local c = color(0.4, 0.5, 0.6)
- SpellColors.SetColorByKey({ keyType = "spellID", primaryKey = 321 }, c)
+ BuffSpellColors:SetColorByKey({ keyType = "spellID", primaryKey = 321 }, c)
- assert.are.same(c, SpellColors.GetColorByKey({ spellID = 321 }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ spellID = 321 }))
assert.are.same({ r = 0.4, g = 0.5, b = 0.6, a = 1 }, c)
end)
it("GetColorByKey accepts legacy textureId field", function()
local c = color(0.3, 0.6, 0.9)
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, nil, nil, 444), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, nil, nil, 444), c)
- assert.are.same(c, SpellColors.GetColorByKey({ textureId = 444 }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ textureId = 444 }))
end)
it("SetColorByKey is a no-op for invalid keys", function()
local c = color(0.2, 0.7, 0.4)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Stored", nil, nil, nil), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Stored", nil, nil, nil), c)
- SpellColors.SetColorByKey(nil, color(1, 1, 1))
- SpellColors.SetColorByKey({}, color(1, 1, 1))
+ BuffSpellColors:SetColorByKey(nil, color(1, 1, 1))
+ BuffSpellColors:SetColorByKey({}, color(1, 1, 1))
- assert.are.same(c, SpellColors.GetColorByKey({ spellName = "Stored" }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ spellName = "Stored" }))
end)
it("ResetColorByKey clears all populated tiers and returns clear flags", function()
local c = color(0.7, 0.1, 0.2)
local key = SpellColors.MakeKey("Sigil of Flame", 204596, 44, 8888)
- SpellColors.SetColorByKey(key, c)
+ BuffSpellColors:SetColorByKey(key, c)
- assert.are.same({ true, true, true, true }, { SpellColors.ResetColorByKey(key) })
+ assert.are.same({ true, true, true, true }, { BuffSpellColors:ResetColorByKey(key) })
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Sigil of Flame" }))
- assert.is_nil(SpellColors.GetColorByKey({ spellID = 204596 }))
- assert.is_nil(SpellColors.GetColorByKey({ cooldownID = 44 }))
- assert.is_nil(SpellColors.GetColorByKey({ textureFileID = 8888 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Sigil of Flame" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellID = 204596 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ cooldownID = 44 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ textureFileID = 8888 }))
end)
it("ResetColorByKey returns all false for unknown or invalid keys", function()
- assert.are.same({ false, false, false, false }, { SpellColors.ResetColorByKey({ spellName = "never-set" }) })
- assert.are.same({ false, false, false, false }, { SpellColors.ResetColorByKey(nil) })
+ assert.are.same({ false, false, false, false }, { BuffSpellColors:ResetColorByKey({ spellName = "never-set" }) })
+ assert.are.same({ false, false, false, false }, { BuffSpellColors:ResetColorByKey(nil) })
end)
it("GetDefaultColor initializes missing profile color storage", function()
- local defaultColor = SpellColors.GetDefaultColor()
+ local defaultColor = BuffSpellColors:GetDefaultColor()
assert.are.same(ns.Constants.BUFFBARS_DEFAULT_COLOR, defaultColor)
assert.is_table(buffBarsConfig.colors)
@@ -373,7 +409,7 @@ describe("SpellColors", function()
defaultColor = "bad",
}
- local defaultColor = SpellColors.GetDefaultColor()
+ local defaultColor = BuffSpellColors:GetDefaultColor()
assert.are.same(ns.Constants.BUFFBARS_DEFAULT_COLOR, defaultColor)
assert.is_table(buffBarsConfig.colors.byName)
@@ -384,29 +420,67 @@ describe("SpellColors", function()
end)
it("SetDefaultColor stores rgb and normalizes alpha to 1", function()
- SpellColors.SetDefaultColor({ r = 0.2, g = 0.4, b = 0.6, a = 0.05 })
+ BuffSpellColors:SetDefaultColor({ r = 0.2, g = 0.4, b = 0.6, a = 0.05 })
- local got = SpellColors.GetDefaultColor()
+ local got = BuffSpellColors:GetDefaultColor()
assert.are.same({ r = 0.2, g = 0.4, b = 0.6, a = 1 }, got)
end)
+ it("stores and reads colors independently by scope", function()
+ local buffColor = color(0.2, 0.3, 0.4)
+ local externalColor = color(0.7, 0.6, 0.5)
+ local key = SpellColors.MakeKey("Scoped Spell", 111, 222, 333)
+
+ BuffSpellColors:SetColorByKey(key, buffColor)
+ ExternalSpellColors:SetColorByKey(key, externalColor)
+
+ assert.are.same(buffColor, BuffSpellColors:GetColorByKey({ spellName = "Scoped Spell" }))
+ assert.are.same(externalColor, ExternalSpellColors:GetColorByKey({ spellName = "Scoped Spell" }))
+ end)
+
+ it("GetColorForBar respects the requested scope", function()
+ local buffColor = color(0.1, 0.2, 0.3)
+ local externalColor = color(0.8, 0.7, 0.6)
+ local frame = makeFrame({
+ spellName = "Scoped Bar",
+ spellID = 444,
+ cooldownID = 555,
+ textureFileID = 666,
+ })
+
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Scoped Bar", 444, 555, 666), buffColor)
+ ExternalSpellColors:SetColorByKey(SpellColors.MakeKey("Scoped Bar", 444, 555, 666), externalColor)
+
+ assert.are.same(buffColor, BuffSpellColors:GetColorForBar(frame))
+ assert.are.same(externalColor, ExternalSpellColors:GetColorForBar(frame))
+ end)
+
+ it("GetDefaultColor and SetDefaultColor are scoped", function()
+ assert.are.same({ r = 0.40, g = 0.78, b = 0.95, a = 1 }, ExternalSpellColors:GetDefaultColor())
+
+ ExternalSpellColors:SetDefaultColor({ r = 0.9, g = 0.8, b = 0.7, a = 0.1 })
+
+ assert.are.same({ r = 0.9, g = 0.8, b = 0.7, a = 1 }, ExternalSpellColors:GetDefaultColor())
+ assert.are.same(ns.Constants.BUFFBARS_DEFAULT_COLOR, BuffSpellColors:GetDefaultColor())
+ end)
+
it("GetColorForBar returns nil for invalid or unhooked frames", function()
- assert.is_nil(SpellColors.GetColorForBar(nil))
- assert.is_nil(SpellColors.GetColorForBar({}))
- assert.is_nil(SpellColors.GetColorForBar(makeFrame({ hooked = false, spellName = "x", textureFileID = 1 })))
+ assert.is_nil(BuffSpellColors:GetColorForBar(nil))
+ assert.is_nil(BuffSpellColors:GetColorForBar({}))
+ assert.is_nil(BuffSpellColors:GetColorForBar(makeFrame({ hooked = false, spellName = "x", textureFileID = 1 })))
end)
it("GetColorForBar resolves color from frame identifiers", function()
local c = color(0.8, 0.2, 0.4)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Throw Glaive", 185123, 66, 1234), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Throw Glaive", 185123, 66, 1234), c)
local frame = makeFrame({ spellName = "Throw Glaive", spellID = 185123, cooldownID = 66, textureFileID = 1234 })
- assert.are.same(c, SpellColors.GetColorForBar(frame))
+ assert.are.same(c, BuffSpellColors:GetColorForBar(frame))
end)
it("GetColorForBar falls back to spellID when other keys are secret", function()
local c = color(0.6, 0.2, 0.9)
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 777, nil, nil), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, 777, nil, nil), c)
local frame = makeFrame({
spellName = markSecret("Secret Name"),
@@ -414,13 +488,13 @@ describe("SpellColors", function()
textureFileID = markSecret(9999),
})
- assert.are.same(c, SpellColors.GetColorForBar(frame))
+ assert.are.same(c, BuffSpellColors:GetColorForBar(frame))
end)
it("GetColorForBar handles every secret-key permutation", function()
local c = color(0.33, 0.44, 0.55)
local base = { "Permutation Bar Spell", 9090, 8080, 7070 }
- SpellColors.SetColorByKey(SpellColors.MakeKey(base[1], base[2], base[3], base[4]), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(base[1], base[2], base[3], base[4]), c)
forEachSecretPermutation(function(mask)
local keys = applySecretMask(base, mask)
@@ -431,7 +505,7 @@ describe("SpellColors", function()
textureFileID = keys[4],
})
- local got = SpellColors.GetColorForBar(frame)
+ local got = BuffSpellColors:GetColorForBar(frame)
if mask[1] and mask[2] and mask[3] and mask[4] then
assert.is_nil(got)
else
@@ -444,54 +518,105 @@ describe("SpellColors", function()
local older = color(0.1, 0.1, 0.1)
local newer = color(0.9, 0.9, 0.2)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Fel Rush", nil, nil, 5678), older)
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, nil, nil, 5678), newer)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Fel Rush", nil, nil, 5678), older)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, nil, nil, 5678), newer)
- SpellColors.ReconcileAllKeys({ SpellColors.MakeKey("Fel Rush", nil, nil, 5678) })
+ BuffSpellColors:ReconcileAllKeys({ SpellColors.MakeKey("Fel Rush", nil, nil, 5678) })
- assert.are.same(newer, SpellColors.GetColorByKey({ spellName = "Fel Rush" }))
+ assert.are.same(newer, BuffSpellColors:GetColorByKey({ spellName = "Fel Rush" }))
+ end)
+
+ it("ReconcileAllKeys operates only within the requested scope", function()
+ local buffColor = color(0.2, 0.2, 0.2)
+ local externalColor = color(0.8, 0.8, 0.1)
+
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Scoped Reconcile", nil, nil, 5678), buffColor)
+ ExternalSpellColors:SetColorByKey(SpellColors.MakeKey(nil, nil, nil, 5678), externalColor)
+
+ ExternalSpellColors:ReconcileAllKeys({ SpellColors.MakeKey("Scoped Reconcile", nil, nil, 5678) })
+
+ assert.are.same(buffColor, BuffSpellColors:GetColorByKey({ spellName = "Scoped Reconcile" }))
+ assert.are.same(externalColor, ExternalSpellColors:GetColorByKey({ spellName = "Scoped Reconcile" }))
end)
it("RemoveEntriesByKeys clears matching persisted and discovered entries", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
local staleColor = color(0.4, 0.5, 0.6)
local keepColor = color(0.8, 0.2, 0.1)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), staleColor)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Keep Me", 12345, 67890, 13579), keepColor)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), staleColor)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Keep Me", 12345, 67890, 13579), keepColor)
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Immolation Aura",
spellID = 258920,
cooldownID = 77,
textureFileID = 9001,
}))
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Keep Me",
spellID = 12345,
cooldownID = 67890,
textureFileID = 13579,
}))
- local removed = SpellColors.RemoveEntriesByKeys({
+ local removed = BuffSpellColors:RemoveEntriesByKeys({
SpellColors.MakeKey("Immolation Aura", 258920, nil, nil),
})
assert.are.equal(1, #removed)
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Immolation Aura" }))
- assert.is_nil(SpellColors.GetColorByKey({ spellID = 258920 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Immolation Aura" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellID = 258920 }))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Keep Me", entries[1].key.spellName)
assert.are.same(keepColor, entries[1].color)
end)
+ it("RemoveEntriesByKeys only removes entries from the requested scope", function()
+ local buffColor = color(0.1, 0.5, 0.9)
+ local externalColor = color(0.9, 0.5, 0.1)
+ local key = SpellColors.MakeKey("Scoped Remove", 2468, nil, nil)
+
+ BuffSpellColors:SetColorByKey(key, buffColor)
+ ExternalSpellColors:SetColorByKey(key, externalColor)
+
+ local removed = ExternalSpellColors:RemoveEntriesByKeys({ key })
+
+ assert.are.equal(1, #removed)
+ assert.are.same(buffColor, BuffSpellColors:GetColorByKey({ spellName = "Scoped Remove" }))
+ assert.is_nil(ExternalSpellColors:GetColorByKey({ spellName = "Scoped Remove" }))
+ end)
+
+ it("RemoveEntriesByKeys only removes discovered entries from the requested scope", function()
+ BuffSpellColors:ClearDiscoveredKeys()
+ ExternalSpellColors:ClearDiscoveredKeys()
+
+ BuffSpellColors:DiscoverBar(makeFrame({
+ spellName = "Scoped Discovered Remove",
+ spellID = 2468,
+ }))
+ ExternalSpellColors:DiscoverBar(makeFrame({
+ spellName = "Scoped Discovered Remove",
+ spellID = 2468,
+ }))
+
+ local removed = ExternalSpellColors:RemoveEntriesByKeys({
+ SpellColors.MakeKey("Scoped Discovered Remove", 2468, nil, nil),
+ })
+
+ assert.are.equal(1, #removed)
+ assert.are.equal(1, #BuffSpellColors:GetAllColorEntries())
+ assert.are.equal("Scoped Discovered Remove", BuffSpellColors:GetAllColorEntries()[1].key.spellName)
+ assert.are.equal(0, #ExternalSpellColors:GetAllColorEntries())
+ end)
+
it("GetAllColorEntries returns deduplicated entries for shared references", function()
local c = color(0.2, 0.3, 0.4)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Eye Beam", 198013, 55, 1111), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Eye Beam", 198013, 55, 1111), c)
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
local entry = entries[1]
@@ -501,7 +626,7 @@ describe("SpellColors", function()
end)
it("GetAllColorEntries derives keys from raw persisted key when metadata is absent", function()
- SpellColors.GetDefaultColor()
+ BuffSpellColors:GetDefaultColor()
buffBarsConfig.colors.bySpellID[currentClassID] = buffBarsConfig.colors.bySpellID[currentClassID] or {}
buffBarsConfig.colors.bySpellID[currentClassID][currentSpecID] = buffBarsConfig.colors.bySpellID[currentClassID][currentSpecID] or {}
@@ -509,7 +634,7 @@ describe("SpellColors", function()
local persistedValue = color(0.5, 0.4, 0.3)
buffBarsConfig.colors.bySpellID[currentClassID][currentSpecID][2468] = { value = persistedValue }
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("spellID", entries[1].key.keyType)
assert.are.equal(2468, entries[1].key.primaryKey)
@@ -517,7 +642,7 @@ describe("SpellColors", function()
end)
it("GetAllColorEntries preserves each store tier raw key when value.keyType mismatches", function()
- SpellColors.GetDefaultColor()
+ BuffSpellColors:GetDefaultColor()
local tierCases = {
{
@@ -556,7 +681,7 @@ describe("SpellColors", function()
value = tierCase.value,
}
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal(tierCase.rawKey, entries[1].key[tierCase.rawField])
assert.is_nil(entries[1].color.keyType)
@@ -564,7 +689,7 @@ describe("SpellColors", function()
end)
it("GetAllColorEntries logically deduplicates fragmented wrappers and prefers newest color", function()
- SpellColors.GetDefaultColor()
+ BuffSpellColors:GetDefaultColor()
for _, storeKey in ipairs({ "byName", "bySpellID", "byCooldownID", "byTexture" }) do
buffBarsConfig.colors[storeKey][currentClassID] = buffBarsConfig.colors[storeKey][currentClassID] or {}
@@ -584,7 +709,7 @@ describe("SpellColors", function()
buffBarsConfig.colors.bySpellID[currentClassID][currentSpecID][203720] = newer
buffBarsConfig.colors.byCooldownID[currentClassID][currentSpecID][11431] = newer
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Demon Spikes", entries[1].key.spellName)
assert.are.equal(203720, entries[1].key.spellID)
@@ -594,47 +719,61 @@ describe("SpellColors", function()
it("GetAllColorEntries keeps reconciled byName raw keys so reset clears byName mappings", function()
local persisted = color(0.6, 0.1, 0.9)
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 1357, nil, nil), persisted)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, 1357, nil, nil), persisted)
- SpellColors.ReconcileAllKeys({ SpellColors.MakeKey("Persisted Name", 1357, nil, nil) })
+ BuffSpellColors:ReconcileAllKeys({ SpellColors.MakeKey("Persisted Name", 1357, nil, nil) })
- assert.are.same(persisted, SpellColors.GetColorByKey({ spellName = "Persisted Name" }))
+ assert.are.same(persisted, BuffSpellColors:GetColorByKey({ spellName = "Persisted Name" }))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Persisted Name", entries[1].key.spellName)
assert.are.equal(1357, entries[1].key.spellID)
- local nameCleared, spellIDCleared = SpellColors.ResetColorByKey(entries[1].key)
+ local nameCleared, spellIDCleared = BuffSpellColors:ResetColorByKey(entries[1].key)
assert.is_true(nameCleared)
assert.is_true(spellIDCleared)
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Persisted Name" }))
- assert.is_nil(SpellColors.GetColorByKey({ spellID = 1357 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Persisted Name" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellID = 1357 }))
end)
it("isolates stored colors by class and specialization", function()
currentClassID, currentSpecID = 12, 1
local c = color(0.3, 0.8, 0.1)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Shared Name", nil, nil, nil), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Shared Name", nil, nil, nil), c)
currentClassID, currentSpecID = 12, 2
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Shared Name" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Shared Name" }))
currentClassID, currentSpecID = 11, 1
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Shared Name" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Shared Name" }))
currentClassID, currentSpecID = 12, 1
- assert.are.same(c, SpellColors.GetColorByKey({ spellName = "Shared Name" }))
+ assert.are.same(c, BuffSpellColors:GetColorByKey({ spellName = "Shared Name" }))
+ end)
+
+ it("ClearCurrentSpecColors only clears the requested scope", function()
+ local buffColor = color(0.1, 0.2, 0.3)
+ local externalColor = color(0.4, 0.5, 0.6)
+ local key = SpellColors.MakeKey("Scoped Clear", nil, nil, nil)
+
+ BuffSpellColors:SetColorByKey(key, buffColor)
+ ExternalSpellColors:SetColorByKey(key, externalColor)
+
+ local cleared = ExternalSpellColors:ClearCurrentSpecColors()
+ assert.are.equal(1, cleared)
+ assert.are.same(buffColor, BuffSpellColors:GetColorByKey({ spellName = "Scoped Clear" }))
+ assert.is_nil(ExternalSpellColors:GetColorByKey({ spellName = "Scoped Clear" }))
end)
it("returns empty entries when class or spec cannot be determined", function()
local c = color(0.4, 0.4, 0.4)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Stored", nil, nil, nil), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Stored", nil, nil, nil), c)
_G.GetSpecialization = function()
return nil
end
- assert.are.same({}, SpellColors.GetAllColorEntries())
+ assert.are.same({}, BuffSpellColors:GetAllColorEntries())
_G.GetSpecialization = function()
return currentSpecID
@@ -642,11 +781,11 @@ describe("SpellColors", function()
_G.UnitClass = function()
return "Demon Hunter", "DEMONHUNTER", nil
end
- assert.are.same({}, SpellColors.GetAllColorEntries())
+ assert.are.same({}, BuffSpellColors:GetAllColorEntries())
end)
it("ClearCurrentSpecColors clears current class/spec entries across all tiers and reports count", function()
- SpellColors.GetDefaultColor()
+ BuffSpellColors:GetDefaultColor()
local otherClassID = currentClassID + 1
local otherSpecID = currentSpecID + 1
@@ -669,7 +808,7 @@ describe("SpellColors", function()
store[otherClassID][otherSpecID][tier.key] = { value = color(0.9, 0.8, 0.7), t = 2 }
end
- local cleared = SpellColors.ClearCurrentSpecColors()
+ local cleared = BuffSpellColors:ClearCurrentSpecColors()
assert.are.equal(4, cleared)
for _, tier in ipairs(tierDefs) do
@@ -685,17 +824,17 @@ describe("SpellColors", function()
local newColor = color(0.8, 0.2, 0.5)
local key = SpellColors.MakeKey("Map Reset Spell", 543210, 654321, 765432)
- SpellColors.SetColorByKey(key, oldColor)
- assert.are.same(oldColor, SpellColors.GetColorByKey({ spellName = "Map Reset Spell" }))
+ BuffSpellColors:SetColorByKey(key, oldColor)
+ assert.are.same(oldColor, BuffSpellColors:GetColorByKey({ spellName = "Map Reset Spell" }))
- local cleared = SpellColors.ClearCurrentSpecColors()
+ local cleared = BuffSpellColors:ClearCurrentSpecColors()
assert.is_true(cleared > 0)
- assert.is_nil(SpellColors.GetColorByKey({ spellName = "Map Reset Spell" }))
- assert.is_nil(SpellColors.GetColorByKey({ spellID = 543210 }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellName = "Map Reset Spell" }))
+ assert.is_nil(BuffSpellColors:GetColorByKey({ spellID = 543210 }))
- SpellColors.SetColorByKey(key, newColor)
- assert.are.same(newColor, SpellColors.GetColorByKey({ spellName = "Map Reset Spell" }))
- assert.are.same(newColor, SpellColors.GetColorByKey({ spellID = 543210 }))
+ BuffSpellColors:SetColorByKey(key, newColor)
+ assert.are.same(newColor, BuffSpellColors:GetColorByKey({ spellName = "Map Reset Spell" }))
+ assert.are.same(newColor, BuffSpellColors:GetColorByKey({ spellID = 543210 }))
end)
---------------------------------------------------------------------------
@@ -703,34 +842,79 @@ describe("SpellColors", function()
---------------------------------------------------------------------------
it("DiscoverBar adds keys to discovered cache and GetAllColorEntries includes them", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Demon Spikes",
spellID = 203720,
}))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Demon Spikes", entries[1].key.spellName)
assert.are.equal(203720, entries[1].key.spellID)
assert.is_nil(entries[1].color)
end)
+ it("DiscoverBar and GetAllColorEntries keep discovered keys scoped", function()
+ BuffSpellColors:ClearDiscoveredKeys()
+ ExternalSpellColors:ClearDiscoveredKeys()
+
+ BuffSpellColors:DiscoverBar(makeFrame({
+ spellName = "Buff Scoped",
+ spellID = 101,
+ }))
+ ExternalSpellColors:DiscoverBar(makeFrame({
+ spellName = "External Scoped",
+ spellID = 202,
+ }))
+
+ local buffEntries = BuffSpellColors:GetAllColorEntries()
+ local externalEntries = ExternalSpellColors:GetAllColorEntries()
+
+ assert.are.equal(1, #buffEntries)
+ assert.are.equal("Buff Scoped", buffEntries[1].key.spellName)
+ assert.are.equal(1, #externalEntries)
+ assert.are.equal("External Scoped", externalEntries[1].key.spellName)
+ end)
+
+ it("ClearDiscoveredKeys only clears the requested scope", function()
+ BuffSpellColors:ClearDiscoveredKeys()
+ ExternalSpellColors:ClearDiscoveredKeys()
+
+ BuffSpellColors:DiscoverBar(makeFrame({
+ spellName = "Buff Scoped",
+ spellID = 101,
+ }))
+ ExternalSpellColors:DiscoverBar(makeFrame({
+ spellName = "External Scoped",
+ spellID = 202,
+ }))
+
+ ExternalSpellColors:ClearDiscoveredKeys()
+
+ local buffEntries = BuffSpellColors:GetAllColorEntries()
+ local externalEntries = ExternalSpellColors:GetAllColorEntries()
+
+ assert.are.equal(1, #buffEntries)
+ assert.are.equal("Buff Scoped", buffEntries[1].key.spellName)
+ assert.are.equal(0, #externalEntries)
+ end)
+
it("DiscoverBar deduplicates matching keys and merges identifiers", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Eye Beam",
spellID = 198013,
}))
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Eye Beam",
cooldownID = 55,
textureFileID = 1111,
}))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Eye Beam", entries[1].key.spellName)
assert.are.equal(198013, entries[1].key.spellID)
@@ -739,19 +923,19 @@ describe("SpellColors", function()
end)
it("discovered keys merge with persisted entries in GetAllColorEntries", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
local c = color(0.5, 0.6, 0.7)
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), c)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), c)
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Immolation Aura",
spellID = 258920,
cooldownID = 77,
textureFileID = 9001,
}))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Immolation Aura", entries[1].key.spellName)
assert.are.equal(258920, entries[1].key.spellID)
@@ -761,63 +945,63 @@ describe("SpellColors", function()
end)
it("ClearDiscoveredKeys wipes the discovered cache", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({ spellName = "Temp Spell" }))
- assert.are.equal(1, #SpellColors.GetAllColorEntries())
+ BuffSpellColors:DiscoverBar(makeFrame({ spellName = "Temp Spell" }))
+ assert.are.equal(1, #BuffSpellColors:GetAllColorEntries())
- SpellColors.ClearDiscoveredKeys()
- assert.are.equal(0, #SpellColors.GetAllColorEntries())
+ BuffSpellColors:ClearDiscoveredKeys()
+ assert.are.equal(0, #BuffSpellColors:GetAllColorEntries())
end)
it("DiscoverBar ignores frames with all secret values", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = markSecret("Secret Name"),
spellID = markSecret(12345),
}))
- assert.are.equal(0, #SpellColors.GetAllColorEntries())
+ assert.are.equal(0, #BuffSpellColors:GetAllColorEntries())
end)
it("ClearDiscoveredKeys on spec change prevents cross-spec leaking", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Demon Spikes",
spellID = 203720,
}))
- assert.are.equal(1, #SpellColors.GetAllColorEntries())
+ assert.are.equal(1, #BuffSpellColors:GetAllColorEntries())
-- Simulate spec change: BuffBars:UpdateLayout calls ClearDiscoveredKeys
currentSpecID = 1
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Fel Rush",
spellID = 195072,
}))
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(1, #entries)
assert.are.equal("Fel Rush", entries[1].key.spellName)
end)
it("ClearDiscoveredKeys on spec change with no new bars yields empty entries", function()
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- SpellColors.DiscoverBar(makeFrame({
+ BuffSpellColors:DiscoverBar(makeFrame({
spellName = "Demon Spikes",
spellID = 203720,
}))
- assert.are.equal(1, #SpellColors.GetAllColorEntries())
+ assert.are.equal(1, #BuffSpellColors:GetAllColorEntries())
-- Simulate spec change: BuffBars:UpdateLayout calls ClearDiscoveredKeys
currentSpecID = 1
- SpellColors.ClearDiscoveredKeys()
+ BuffSpellColors:ClearDiscoveredKeys()
- local entries = SpellColors.GetAllColorEntries()
+ local entries = BuffSpellColors:GetAllColorEntries()
assert.are.equal(0, #entries)
end)
end)
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 87416061..caef3e8b 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -9,8 +9,13 @@ describe("BuffBarsOptions", function()
local originalGlobals
local BuffBarsOptions
local SpellColors
+ local BuffSpellColors
+ local ExternalSpellColors
local ns
+ local profile
+ local defaults
local printedMessages
+ local addonEventCallbacks
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
@@ -49,7 +54,9 @@ describe("BuffBarsOptions", function()
before_each(function()
TestHelpers.SetupOptionsGlobals()
- local profile, defaults = TestHelpers.MakeOptionsProfile()
+ profile, defaults = TestHelpers.MakeOptionsProfile()
+ profile.externalBars.enabled = true
+ defaults.externalBars.enabled = true
ns = select(2, TestHelpers.SetupOptionsEnv(profile, defaults))
_G.UnitClass = function()
@@ -117,6 +124,7 @@ describe("BuffBarsOptions", function()
ns.DebugAssert = function() end
ns.Log = function() end
printedMessages = {}
+ addonEventCallbacks = {}
ns.Print = function(message)
printedMessages[#printedMessages + 1] = message
end
@@ -137,8 +145,8 @@ describe("BuffBarsOptions", function()
-- Load SpellColors
ns.Addon = {
db = {
- profile = { buffBars = {} },
- defaults = { profile = { buffBars = {} } },
+ profile = profile,
+ defaults = { profile = defaults },
},
BuffBars = {
IsEditLocked = function()
@@ -148,8 +156,42 @@ describe("BuffBarsOptions", function()
return {}
end,
},
+ ExternalBars = {
+ IsEditLocked = function()
+ return false, nil
+ end,
+ GetActiveSpellData = function()
+ return {}
+ end,
+ },
ConfirmReloadUI = function() end,
ShowConfirmDialog = function() end,
+ RegisterEvent = function(_, event, callback)
+ local callbacks = addonEventCallbacks[event] or {}
+ callbacks[#callbacks + 1] = callback
+ addonEventCallbacks[event] = callbacks
+ end,
+ UnregisterEvent = function(_, event, callback)
+ local callbacks = addonEventCallbacks[event]
+ if not callbacks then
+ return
+ end
+
+ if callback == nil then
+ addonEventCallbacks[event] = nil
+ return
+ end
+
+ for index = #callbacks, 1, -1 do
+ if callbacks[index] == callback then
+ table.remove(callbacks, index)
+ end
+ end
+
+ if #callbacks == 0 then
+ addonEventCallbacks[event] = nil
+ end
+ end,
NewModule = function(_, name)
return { moduleName = name }
end,
@@ -157,13 +199,18 @@ describe("BuffBarsOptions", function()
TestHelpers.LoadChunk("SpellColors.lua", "Unable to load SpellColors.lua")(nil, ns)
SpellColors = ns.SpellColors
+ BuffSpellColors = SpellColors.Get(ns.Constants.SCOPE_BUFFBARS)
+ ExternalSpellColors = SpellColors.Get(ns.Constants.SCOPE_EXTERNALBARS)
-- Load Options (includes SettingsBuilder adapter)
TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
+ TestHelpers.LoadChunk("UI/SpellColorsPage.lua", "Unable to load UI/SpellColorsPage.lua")(nil, ns)
+
-- Load BuffBarsOptions
TestHelpers.LoadChunk("UI/BuffBarsOptions.lua", "Unable to load UI/BuffBarsOptions.lua")(nil, ns)
+ TestHelpers.LoadChunk("UI/ExternalBarsOptions.lua", "Unable to load UI/ExternalBarsOptions.lua")(nil, ns)
BuffBarsOptions = ns.BuffBarsOptions
end)
@@ -239,7 +286,7 @@ describe("BuffBarsOptions", function()
assert.are.equal("buffBars", BuffBarsOptions.key)
assert.are.equal(ns.L["AURA_BARS"], BuffBarsOptions.name)
assert.are.equal("main", BuffBarsOptions.pages[1].key)
- assert.are.equal("spellColors", BuffBarsOptions.pages[2].key)
+ assert.is_nil(BuffBarsOptions.pages[2])
end)
it("_GetSpellColorsPageState hides the secret-name warning when all bar names are available", function()
@@ -326,7 +373,7 @@ describe("BuffBarsOptions", function()
end)
local function registerSpellColorsSpec()
- local spellColorsSpec = assert(BuffBarsOptions.pages[2])
+ local spellColorsSpec = assert(ns.SpellColorsPage.CreatePage(ns.L["SPELL_COLORS_SUBCAT"]))
local refreshCalls = {}
local fakePage = {
Refresh = function()
@@ -341,6 +388,29 @@ describe("BuffBarsOptions", function()
return spellColorsSpec, refreshCalls
end
+ local function getSpellColorsRow(spellColorsSpec, rowID)
+ for _, row in ipairs(spellColorsSpec.rows or {}) do
+ if row.id == rowID then
+ return row
+ end
+ end
+
+ return nil
+ end
+
+ local function getSpellColorCollectionItems(spellColorsSpec, sectionKey)
+ local row = assert(getSpellColorsRow(spellColorsSpec, sectionKey .. "SpellColorCollection"))
+ return row.items()
+ end
+
+ local function getItemLabels(items)
+ local labels = {}
+ for _, item in ipairs(items or {}) do
+ labels[#labels + 1] = item.label
+ end
+ return labels
+ end
+
it("does not add the old configure spell colors shortcut to aura bars", function()
local buttonRows = {}
for _, row in ipairs(BuffBarsOptions.pages[1].rows) do
@@ -354,8 +424,249 @@ describe("BuffBarsOptions", function()
assert.are.equal(ns.L["LAYOUT_PAGE_MOVED_BUTTON_TEXT"], buttonRows[1].buttonText)
end)
+ it("orders the shared spell colors sections with aura bars before external cooldowns", function()
+ local spellColorsSpec = registerSpellColorsSpec()
+ local rowIDs = {}
+
+ for _, row in ipairs(spellColorsSpec.rows) do
+ rowIDs[#rowIDs + 1] = row.id
+ end
+
+ assert.same({
+ "buffBarsSpellColorsPageActions",
+ "spellColorsDescription",
+ "buffBarsSpellColorsWarning",
+ "buffBarsSpellColorCollection",
+ "buffBarsSecretNameDescription",
+ "externalBarsSpellColorsPageActions",
+ "externalBarsSpellColorsWarning",
+ "externalBarsSpellColorCollection",
+ "externalBarsSecretNameDescription",
+ }, rowIDs)
+ end)
+
+ it("keeps a single action set per spell color section", function()
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actionRowCount = 0
+
+ for _, row in ipairs(spellColorsSpec.rows) do
+ if row.type == "pageActions" then
+ actionRowCount = actionRowCount + 1
+ end
+ end
+
+ assert.are.equal(2, actionRowCount)
+ assert.is_nil(assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorCollection")).onDefault)
+ assert.is_nil(assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorCollection")).onDefault)
+ end)
+
+ it("refreshes the registered page on combat enter and leave", function()
+ local _, refreshCalls = registerSpellColorsSpec()
+ local enterCallbacks = assert(addonEventCallbacks.PLAYER_REGEN_DISABLED)
+ local leaveCallbacks = assert(addonEventCallbacks.PLAYER_REGEN_ENABLED)
+
+ assert.are.equal(1, #enterCallbacks)
+ assert.are.equal(1, #leaveCallbacks)
+
+ enterCallbacks[1](ns.Addon, "PLAYER_REGEN_DISABLED")
+ leaveCallbacks[1](ns.Addon, "PLAYER_REGEN_ENABLED")
+
+ assert.are.same({
+ ns.L["SPELL_COLORS_SUBCAT"],
+ ns.L["SPELL_COLORS_SUBCAT"],
+ }, refreshCalls)
+ end)
+
+ it("registers combat refresh callbacks once and refreshes the latest registered page", function()
+ local spellColorsSpec = assert(ns.SpellColorsPage.CreatePage(ns.L["SPELL_COLORS_SUBCAT"]))
+ local firstRefreshCalls = 0
+ local secondRefreshCalls = 0
+
+ spellColorsSpec.SetRegisteredPage({
+ Refresh = function()
+ firstRefreshCalls = firstRefreshCalls + 1
+ end,
+ })
+ spellColorsSpec.SetRegisteredPage({
+ Refresh = function()
+ secondRefreshCalls = secondRefreshCalls + 1
+ end,
+ })
+
+ local enterCallbacks = assert(addonEventCallbacks.PLAYER_REGEN_DISABLED)
+ local leaveCallbacks = assert(addonEventCallbacks.PLAYER_REGEN_ENABLED)
+
+ assert.are.equal(1, #enterCallbacks)
+ assert.are.equal(1, #leaveCallbacks)
+
+ enterCallbacks[1](ns.Addon, "PLAYER_REGEN_DISABLED")
+
+ assert.are.equal(0, firstRefreshCalls)
+ assert.are.equal(1, secondRefreshCalls)
+ end)
+
+ it("keeps each section's rows and default colors scoped to its own store", function()
+ local buffDefaultColor = { r = 0.2, g = 0.3, b = 0.4, a = 1 }
+ local externalDefaultColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+ local buffColor = { r = 0.3, g = 0.4, b = 0.5, a = 1 }
+ local externalColor = { r = 0.8, g = 0.4, b = 0.2, a = 1 }
+
+ BuffSpellColors:SetDefaultColor(buffDefaultColor)
+ ExternalSpellColors:SetDefaultColor(externalDefaultColor)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Buff Scoped", 111, 222, 333), buffColor)
+ ExternalSpellColors:SetColorByKey(SpellColors.MakeKey("External Scoped", 444, 555, 666), externalColor)
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local buffItems = getSpellColorCollectionItems(spellColorsSpec, "buffBars")
+ local externalItems = getSpellColorCollectionItems(spellColorsSpec, "externalBars")
+
+ assert.same({ ns.L["DEFAULT_COLOR"], "Buff Scoped" }, getItemLabels(buffItems))
+ assert.same({ ns.L["DEFAULT_COLOR"], "External Scoped" }, getItemLabels(externalItems))
+ assert.are.same(buffDefaultColor, buffItems[1].color.value)
+ assert.are.same(externalDefaultColor, externalItems[1].color.value)
+ assert.are.same(buffColor, buffItems[2].color.value)
+ assert.are.same(externalColor, externalItems[2].color.value)
+ end)
+
+ it("routes section swatch writes to the matching scope only", function()
+ local buffKey = SpellColors.MakeKey("Buff Scoped", 111, 222, 333)
+ local externalKey = SpellColors.MakeKey("External Scoped", 444, 555, 666)
+ local buffDefaultColor = { r = 0.2, g = 0.3, b = 0.4, a = 1 }
+ local externalDefaultColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+ local buffColor = { r = 0.3, g = 0.4, b = 0.5, a = 1 }
+ local externalColor = { r = 0.8, g = 0.4, b = 0.2, a = 1 }
+ local pickedDefaultColor = { r = 0.9, g = 0.8, b = 0.7, a = 1 }
+ local pickedEntryColor = { r = 0.1, g = 0.6, b = 0.9, a = 1 }
+ local pickerCalls = 0
+
+ BuffSpellColors:SetDefaultColor(buffDefaultColor)
+ ExternalSpellColors:SetDefaultColor(externalDefaultColor)
+ BuffSpellColors:SetColorByKey(buffKey, buffColor)
+ ExternalSpellColors:SetColorByKey(externalKey, externalColor)
+
+ ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
+ pickerCalls = pickerCalls + 1
+ assert.is_false(hasOpacity)
+ onChange(pickerCalls == 1 and pickedDefaultColor or pickedEntryColor)
+ end
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local externalItems = getSpellColorCollectionItems(spellColorsSpec, "externalBars")
+
+ externalItems[1].color.onClick()
+ externalItems[2].color.onClick()
+
+ assert.are.same(buffDefaultColor, BuffSpellColors:GetDefaultColor())
+ assert.are.same(pickedDefaultColor, ExternalSpellColors:GetDefaultColor())
+ assert.are.same(buffColor, BuffSpellColors:GetColorByKey(buffKey))
+ assert.are.same(pickedEntryColor, ExternalSpellColors:GetColorByKey(externalKey))
+ end)
+
+ it("enables reconcile and remove-stale actions per section state", function()
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Buff Incomplete", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+ ExternalSpellColors:SetColorByKey(SpellColors.MakeKey("External Complete", 102342, 77, 9001), {
+ r = 0.6, g = 0.5, b = 0.4, a = 1,
+ })
+
+ local confirmText
+ ns.Addon.ConfirmReloadUI = function(_, text)
+ confirmText = text
+ end
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local buffActions = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsPageActions")).actions
+ local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+
+ assert.is_true(buffActions[1].enabled())
+ assert.is_true(buffActions[2].enabled())
+ assert.is_false(externalActions[1].enabled())
+ assert.is_false(externalActions[2].enabled())
+
+ buffActions[1].onClick()
+
+ assert.are.equal(ns.L["SPELL_COLORS_SECRET_NAMES_DESC"], confirmText)
+ end)
+
+ it("reset action clears only the targeted section", function()
+ local buffKey = SpellColors.MakeKey("Buff Keep", 111, 222, 333)
+ local externalKey = SpellColors.MakeKey("External Reset", 444, 555, 666)
+ local buffDefaultColor = { r = 0.2, g = 0.3, b = 0.4, a = 1 }
+ local externalResetDefaultColor = ns.Constants.BUFFBARS_DEFAULT_COLOR
+ local externalCustomDefaultColor = { r = 0.9, g = 0.8, b = 0.7, a = 1 }
+
+ BuffSpellColors:SetDefaultColor(buffDefaultColor)
+ ExternalSpellColors:SetDefaultColor(externalCustomDefaultColor)
+ BuffSpellColors:SetColorByKey(buffKey, { r = 0.3, g = 0.4, b = 0.5, a = 1 })
+ ExternalSpellColors:SetColorByKey(externalKey, { r = 0.6, g = 0.5, b = 0.4, a = 1 })
+
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+
+ externalActions[3].onClick()
+
+ assert.are.same(buffDefaultColor, BuffSpellColors:GetDefaultColor())
+ assert.are.same(externalResetDefaultColor, ExternalSpellColors:GetDefaultColor())
+ assert.are.same({ r = 0.3, g = 0.4, b = 0.5, a = 1 }, BuffSpellColors:GetColorByKey(buffKey))
+ assert.is_nil(ExternalSpellColors:GetColorByKey(externalKey))
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
+ end)
+
+ it("remove stale action clears only the targeted section", function()
+ local buffKey = SpellColors.MakeKey("Buff Stale", 111, nil, nil)
+ local externalKey = SpellColors.MakeKey("External Stale", 444, nil, nil)
+ local acceptFn
+
+ BuffSpellColors:SetColorByKey(buffKey, { r = 0.2, g = 0.3, b = 0.4, a = 1 })
+ ExternalSpellColors:SetColorByKey(externalKey, { r = 0.6, g = 0.5, b = 0.4, a = 1 })
+ ns.Addon.ShowConfirmDialog = function(_, _, _, _, _, onAccept)
+ acceptFn = onAccept
+ end
+
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+
+ externalActions[2].onClick()
+ assert.is_function(acceptFn)
+
+ acceptFn()
+
+ assert.are.same({ r = 0.2, g = 0.3, b = 0.4, a = 1 }, BuffSpellColors:GetColorByKey(buffKey))
+ assert.is_nil(ExternalSpellColors:GetColorByKey(externalKey))
+ assert.are.same({
+ ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("External Stale"),
+ }, printedMessages)
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
+ end)
+
+ it("greys out a disabled section without affecting the enabled section", function()
+ profile.externalBars.enabled = false
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Buff Enabled", 111, 222, 333), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+ ExternalSpellColors:SetColorByKey(SpellColors.MakeKey("External Disabled", 444, 555, 666), {
+ r = 0.6, g = 0.5, b = 0.4, a = 1,
+ })
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local buffHeader = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsPageActions"))
+ local externalHeader = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions"))
+ local buffItems = getSpellColorCollectionItems(spellColorsSpec, "buffBars")
+ local externalItems = getSpellColorCollectionItems(spellColorsSpec, "externalBars")
+
+ assert.is_false(buffHeader.disabled())
+ assert.is_true(externalHeader.disabled())
+ assert.is_true(buffItems[1].color.enabled())
+ assert.is_false(externalItems[1].color.enabled())
+ assert.are.equal(0.5, externalItems[1].alpha)
+ assert.is_true(externalItems[1].iconDesaturated)
+ assert.are.equal(0.5, externalItems[2].alpha)
+ assert.is_true(externalItems[2].iconDesaturated)
+ end)
+
it("ctrl-hovering a spell color collection row shows all keys for that row", function()
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
_G.IsControlKeyDown = function()
@@ -400,33 +711,13 @@ describe("BuffBarsOptions", function()
defaultItem.color.onClick()
- assert.are.same(selectedColor, ns.SpellColors.GetDefaultColor())
- assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
- end)
-
- it("collection defaults reset the default color and custom spell rows", function()
- local scheduledReason
-
- SpellColors.SetDefaultColor({ r = 0.7, g = 0.6, b = 0.5, a = 1 })
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
- r = 0.2, g = 0.3, b = 0.4, a = 1,
- })
- ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
- scheduledReason = reason
- end
-
- local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- spellColorsSpec.rows[4].onDefault()
-
- assert.are.same({}, SpellColors.GetAllColorEntries())
- assert.are.same(ns.Constants.BUFFBARS_DEFAULT_COLOR, SpellColors.GetDefaultColor())
+ assert.are.same(selectedColor, BuffSpellColors:GetDefaultColor())
assert.are.equal("OptionsChanged", scheduledReason)
assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
it("header actions disable reconcile and remove stale when every row is complete", function()
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
@@ -438,7 +729,7 @@ describe("BuffBarsOptions", function()
end)
it("header actions enable reconcile and remove stale for incomplete rows outside restricted areas", function()
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
@@ -453,21 +744,37 @@ describe("BuffBarsOptions", function()
_G.IsInInstance = function()
return true, "party"
end
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actions = spellColorsSpec.rows[1].actions
+
+ assert.is_false(actions[1].enabled())
+ assert.is_false(actions[2].enabled())
+ end)
+
+ it("header actions disable all three actions while spell color editing is locked", function()
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
+ ns.Addon.BuffBars.IsEditLocked = function()
+ return true, "combat"
+ end
local spellColorsSpec = registerSpellColorsSpec()
local actions = spellColorsSpec.rows[1].actions
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
+ assert.is_false(actions[3].enabled())
end)
it("reconcile action uses ConfirmReloadUI for incomplete rows", function()
local confirmText
- SpellColors.SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey(nil, 258920, 77, 9001), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
ns.Addon.ConfirmReloadUI = function(_, text)
@@ -488,7 +795,7 @@ describe("BuffBarsOptions", function()
local acceptFn
local scheduledReason
- SpellColors.SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
@@ -517,11 +824,49 @@ describe("BuffBarsOptions", function()
acceptFn()
- assert.are.same({}, ns.SpellColors.GetAllColorEntries())
+ assert.are.same({}, BuffSpellColors:GetAllColorEntries())
assert.are.same({
ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("Immolation Aura"),
}, printedMessages)
assert.are.equal("OptionsChanged", scheduledReason)
assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
+
+ it("header action clicks are no-ops while spell color editing is locked", function()
+ local confirmText
+ local popupKey
+ local scheduledReason
+ local originalDefaultColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+
+ BuffSpellColors:SetDefaultColor(originalDefaultColor)
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+ ns.Addon.BuffBars.IsEditLocked = function()
+ return true, "combat"
+ end
+ ns.Addon.ConfirmReloadUI = function(_, text)
+ confirmText = text
+ end
+ ns.Addon.ShowConfirmDialog = function(_, key)
+ popupKey = key
+ end
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReason = reason
+ end
+
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local actions = spellColorsSpec.rows[1].actions
+
+ actions[1].onClick()
+ actions[2].onClick()
+ actions[3].onClick()
+
+ assert.is_nil(confirmText)
+ assert.is_nil(popupKey)
+ assert.is_nil(scheduledReason)
+ assert.are.same(originalDefaultColor, BuffSpellColors:GetDefaultColor())
+ assert.are.equal(1, #BuffSpellColors:GetAllColorEntries())
+ assert.are.same({}, refreshCalls)
+ end)
end)
diff --git a/Tests/UI/BuffBarsSettingsOptions_spec.lua b/Tests/UI/BuffBarsSettingsOptions_spec.lua
index 8a68a241..ef00f406 100644
--- a/Tests/UI/BuffBarsSettingsOptions_spec.lua
+++ b/Tests/UI/BuffBarsSettingsOptions_spec.lua
@@ -52,6 +52,7 @@ describe("BuffBarsOptions settings getters/setters/defaults", function()
ns.Addon.ConfirmReloadUI = function(_, _, cb) if cb then cb() end end
settings = TestHelpers.CollectSettings(function()
+ TestHelpers.LoadChunk("UI/SpellColorsPage.lua", "SpellColorsPage")(nil, ns)
TestHelpers.LoadChunk("UI/BuffBarsOptions.lua", "BuffBarsOptions")(nil, ns)
TestHelpers.RegisterSectionSpec(SB, ns.BuffBarsOptions)
capturedPage = ns.BuffBarsOptions.pages[1]
diff --git a/Tests/UI/ExternalBarsOptions_spec.lua b/Tests/UI/ExternalBarsOptions_spec.lua
new file mode 100644
index 00000000..9b37f11d
--- /dev/null
+++ b/Tests/UI/ExternalBarsOptions_spec.lua
@@ -0,0 +1,79 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local TestHelpers = assert(
+ loadfile("Tests/TestHelpers.lua") or loadfile("TestHelpers.lua"),
+ "Unable to load Tests/TestHelpers.lua"
+)()
+
+describe("ExternalBarsOptions", function()
+ local originalGlobals
+ local ExternalBarsOptions
+ local ns
+
+ local function getRow(rows, rowID)
+ for _, row in ipairs(rows or {}) do
+ if row.id == rowID then
+ return row
+ end
+ end
+ return nil
+ end
+
+ setup(function()
+ originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
+ end)
+
+ teardown(function()
+ TestHelpers.RestoreGlobals(originalGlobals)
+ end)
+
+ before_each(function()
+ TestHelpers.SetupOptionsGlobals()
+
+ local profile, defaults = TestHelpers.MakeOptionsProfile()
+ profile.externalBars.enabled = true
+ defaults.externalBars.enabled = true
+ ns = select(2, TestHelpers.SetupOptionsEnv(profile, defaults))
+
+ ns.Addon.ExternalBars = {
+ IsEditLocked = function()
+ return false
+ end,
+ }
+
+ TestHelpers.LoadChunk("UI/SpellColorsPage.lua", "Unable to load UI/SpellColorsPage.lua")(nil, ns)
+ TestHelpers.LoadChunk("UI/ExternalBarsOptions.lua", "Unable to load UI/ExternalBarsOptions.lua")(nil, ns)
+ ExternalBarsOptions = ns.ExternalBarsOptions
+ end)
+
+ it("exports the external cooldowns section with only the main page", function()
+ assert.are.equal("externalBars", ExternalBarsOptions.key)
+ assert.are.equal(ns.L["EXTERNAL_BARS"], ExternalBarsOptions.name)
+ assert.are.equal(1, #ExternalBarsOptions.pages)
+ assert.are.equal("main", ExternalBarsOptions.pages[1].key)
+ end)
+
+ it("does not duplicate the default color row on the main page", function()
+ assert.is_nil(getRow(ExternalBarsOptions.pages[1].rows, "defaultColor"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "hideOriginalIcons"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "showIcon"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "showSpellName"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "showDuration"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "height"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "verticalSpacing"))
+ assert.is_not_nil(getRow(ExternalBarsOptions.pages[1].rows, "fontOverride"))
+ end)
+
+ it("registers the external bars section with the shared spell colors page", function()
+ local sharedPage = ns.SpellColorsPage.CreatePage(ns.L["SPELL_COLORS_SUBCAT"])
+
+ assert.are.equal("spellColors", sharedPage.key)
+ assert.are.equal("externalBarsSpellColorsPageActions", sharedPage.rows[1].id)
+ assert.are.equal("pageActions", sharedPage.rows[1].type)
+ assert.are.equal("externalBarsSpellColorsWarning", sharedPage.rows[3].id)
+ assert.are.equal("externalBarsSpellColorCollection", sharedPage.rows[4].id)
+ assert.are.equal("externalBarsSecretNameDescription", sharedPage.rows[5].id)
+ end)
+end)
diff --git a/Tests/UI/OptionsSections_spec.lua b/Tests/UI/OptionsSections_spec.lua
index cb336c64..f3e157fb 100644
--- a/Tests/UI/OptionsSections_spec.lua
+++ b/Tests/UI/OptionsSections_spec.lua
@@ -97,6 +97,12 @@ describe("Options root assembly", function()
ns.ExtraIconsOptions = placeholderSection("extraIcons", ns.L["EXTRA_ICONS"])
ns.ProfileOptions = placeholderSection("profile", ns.L["PROFILES"])
ns.AdvancedOptions = placeholderSection("advancedOptions", ns.L["ADVANCED_OPTIONS"])
+ ns.SpellColorsPage = {
+ CreatePage = function(name)
+ return { key = "spellColors", name = name, rows = {} }
+ end,
+ SetRegisteredPage = function() end,
+ }
assert.is_table(createdModule)
createdModule:OnInitialize()
@@ -113,6 +119,7 @@ describe("Options root assembly", function()
"runeBar",
"buffBars",
"extraIcons",
+ "spellColors",
"profile",
"advancedOptions",
}) do
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index 5f51198e..40eeea5f 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -372,6 +372,12 @@ describe("OptionUtil", function()
ns.ExtraIconsOptions = placeholderSection("extraIcons", ns.L["EXTRA_ICONS"])
ns.ProfileOptions = placeholderSection("profile", ns.L["PROFILES"])
ns.AdvancedOptions = placeholderSection("advancedOptions", ns.L["ADVANCED_OPTIONS"])
+ ns.SpellColorsPage = {
+ CreatePage = function(name)
+ return { key = "spellColors", name = name, rows = {} }
+ end,
+ SetRegisteredPage = function() end,
+ }
optionsModule:OnInitialize()
generalCategory = ns.Settings:GetPage("general", "main")._category
diff --git a/UI/BuffBarsOptions.lua b/UI/BuffBarsOptions.lua
index ae48b859..3d98b753 100644
--- a/UI/BuffBarsOptions.lua
+++ b/UI/BuffBarsOptions.lua
@@ -6,391 +6,19 @@ local _, ns = ...
local C = ns.Constants
local L = ns.L
-local REMOVE_STALE_SPELL_COLORS_POPUP = "ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS"
-local SPELL_COLORS_HEADER_BUTTON_WIDTH = 100
-
---- Generates the merged list of spell color rows from spell color entries.
----@param entries { key: ECM_SpellColorKey }[]|nil
----@return { key: ECM_SpellColorKey, textureFileID: number|nil }[]
-local function buildSpellColorRows(entries)
- local rows = {}
-
- for _, entry in ipairs(entries or {}) do
- local normalized = ns.SpellColors.NormalizeKey(type(entry) == "table" and entry.key)
- if normalized then
- local merged = false
- for _, row in ipairs(rows) do
- if row.key:Matches(normalized) then
- row.key = row.key:Merge(normalized) or row.key
- row.textureFileID = row.key.textureFileID or row.textureFileID
- merged = true
- break
- end
- end
-
- if not merged then
- rows[#rows + 1] = {
- key = normalized,
- textureFileID = normalized.textureFileID,
- }
- end
- end
- end
-
- return rows
-end
-
----@param key ECM_SpellColorKey|table|nil
----@return { hasSecretName: boolean, isIncomplete: boolean }|nil
-local function getSpellColorKeyState(key)
- local normalized = ns.SpellColors.NormalizeKey(key)
- local primaryKey = normalized and normalized.primaryKey or (type(key) == "table" and key.primaryKey)
- if not normalized and type(primaryKey) ~= "string" then
- return nil
- end
-
- return {
- hasSecretName = type(primaryKey) == "string" and (issecretvalue(primaryKey) or primaryKey == ""),
- isIncomplete = normalized ~= nil and (normalized.spellName == nil
- or normalized.spellID == nil
- or normalized.cooldownID == nil
- or normalized.textureFileID == nil),
- }
-end
-
----@return boolean
-local function isSpellColorsReconcileRestricted()
- return _G.UnitAffectingCombat("player") or InCombatLockdown() or IsInInstance()
-end
-
----@param rows { key: ECM_SpellColorKey }[]|nil
----@return { hasRowsNeedingReconcile: boolean, showSecretNameWarning: boolean, warningText: string, canReconcile: boolean }
-local function getSpellColorsPageState(rows)
- local state = {
- hasRowsNeedingReconcile = false,
- showSecretNameWarning = false,
- warningText = "",
- canReconcile = false,
- }
-
- for _, row in ipairs(rows or {}) do
- local keyState = getSpellColorKeyState(row and row.key)
- if keyState then
- state.hasRowsNeedingReconcile = state.hasRowsNeedingReconcile or keyState.isIncomplete
- state.showSecretNameWarning = state.showSecretNameWarning or keyState.hasSecretName
-
- if state.hasRowsNeedingReconcile and state.showSecretNameWarning then
- break
- end
- end
- end
-
- if InCombatLockdown() then
- state.warningText = L["SPELL_COLORS_COMBAT_WARNING"]
- end
-
- state.canReconcile = state.hasRowsNeedingReconcile and not isSpellColorsReconcileRestricted()
-
- return state
-end
-
----@param rows { key: ECM_SpellColorKey }[]|nil
----@return { key: ECM_SpellColorKey }[]
-local function collectIncompleteSpellColorRows(rows)
- local incompleteRows = {}
-
- for _, row in ipairs(rows or {}) do
- local keyState = getSpellColorKeyState(row and row.key)
- if keyState and keyState.isIncomplete then
- incompleteRows[#incompleteRows + 1] = row
- end
- end
-
- return incompleteRows
-end
-
----@param key ECM_SpellColorKey|table|nil
----@return string
-local function getSpellColorRowName(key)
- local normalized = ns.SpellColors.NormalizeKey(key)
- local primaryKey = normalized and normalized.primaryKey or nil
-
- if type(primaryKey) == "string" then
- return primaryKey
- end
-
- return "Bar (" .. tostring(primaryKey) .. ")"
-end
-
----@param key ECM_SpellColorKey|table|nil
----@return string[]
-local function buildSpellColorKeyTooltipLines(key)
- local normalized = ns.SpellColors.NormalizeKey(key)
- if not normalized then
- return {}
- end
-
- local lines = {}
-
- local function addLine(formatString, value)
- local valueType = type(value)
- if valueType == "string" or valueType == "number" then
- lines[#lines + 1] = string.format(formatString, value)
- end
- end
-
- addLine(L["SPELL_COLORS_KEY_SPELL_NAME"], normalized.spellName)
- addLine(L["SPELL_COLORS_KEY_SPELL_ID"], normalized.spellID)
- addLine(L["SPELL_COLORS_KEY_COOLDOWN_ID"], normalized.cooldownID)
- addLine(L["SPELL_COLORS_KEY_TEXTURE_FILE_ID"], normalized.textureFileID)
-
- return lines
-end
-
----@param owner Frame
----@param data { key: ECM_SpellColorKey }|nil
-local function maybeShowSpellColorKeyTooltip(owner, data)
- if not IsControlKeyDown() then
- return
- end
-
- local lines = buildSpellColorKeyTooltipLines(type(data) == "table" and data.key)
- if #lines == 0 then
- return
- end
-
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], 1, 1, 1, 1)
-
- for _, line in ipairs(lines) do
- GameTooltip:AddLine(line, 1, 1, 1, true)
- end
-
- GameTooltip:Show()
-end
-
---------------------------------------------------------------------------------
--- Canvas Frame for Spell Colors
---------------------------------------------------------------------------------
-
-local function createSpellColorPage(subcatName)
- local registeredPage
- local function setRegisteredPage(page)
- registeredPage = page
- end
-
- local function refreshPage()
- if registeredPage then
- registeredPage:Refresh()
- end
- end
-
- local function resetAllSpellColors()
- ns.SpellColors.ClearCurrentSpecColors()
- ns.SpellColors.SetDefaultColor(C.BUFFBARS_DEFAULT_COLOR)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshPage()
- end
-
- local function reconcileSpellColors()
- ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
- end
-
- local function removeStaleSpellColors()
- local staleRows = collectIncompleteSpellColorRows(buildSpellColorRows(ns.SpellColors.GetAllColorEntries()))
- if #staleRows == 0 then
- return
- end
-
- ns.Addon:ShowConfirmDialog(
- REMOVE_STALE_SPELL_COLORS_POPUP,
- L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
- L["REMOVE"],
- L["SPELL_COLORS_DONT_REMOVE"],
- function()
- local staleKeys = {}
- for _, row in ipairs(staleRows) do
- staleKeys[#staleKeys + 1] = row.key
- end
-
- local removedKeys = ns.SpellColors.RemoveEntriesByKeys(staleKeys)
- for _, key in ipairs(removedKeys) do
- ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key)))
- end
-
- if #removedKeys > 0 then
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshPage()
- end
- end
- )
- end
-
- local function getRows()
- return buildSpellColorRows(ns.SpellColors.GetAllColorEntries())
- end
-
- local function buildSpellColorItems()
- local items = {}
- local rows = getRows()
-
- items[#items + 1] = {
- label = L["DEFAULT_COLOR"],
- color = {
- value = ns.SpellColors.GetDefaultColor(),
- enabled = function()
- local locked = ns.Addon.BuffBars:IsEditLocked()
- return not locked
- end,
- onClick = function()
- local locked = ns.Addon.BuffBars:IsEditLocked()
-
- if locked then
- return
- end
-
- ns.OptionUtil.OpenColorPicker(ns.SpellColors.GetDefaultColor(), false, function(color)
- ns.SpellColors.SetDefaultColor(color)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshPage()
- end)
- end,
- },
- }
-
- for _, row in ipairs(rows) do
- items[#items + 1] = {
- label = getSpellColorRowName(row.key),
- icon = row.textureFileID,
- color = {
- value = ns.SpellColors.GetColorByKey(row.key) or ns.SpellColors.GetDefaultColor(),
- onClick = function()
- local locked = ns.Addon.BuffBars:IsEditLocked()
-
- if locked then
- return
- end
-
- local current = ns.SpellColors.GetColorByKey(row.key) or ns.SpellColors.GetDefaultColor()
- ns.OptionUtil.OpenColorPicker(current, false, function(color)
- ns.SpellColors.SetColorByKey(row.key, color)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshPage()
- end)
- end,
- },
- onEnter = function(owner)
- maybeShowSpellColorKeyTooltip(owner, row)
- end,
- onLeave = function()
- GameTooltip_Hide()
- end,
- }
- end
- return items
- end
-
- local pageSpec = {
- key = "spellColors",
- name = subcatName,
- rows = {
- {
- id = "spellColorsPageActions",
- type = "pageActions",
- name = subcatName,
- actions = {
- {
- text = L["SPELL_COLORS_RECONCILE_BUTTON"],
- width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
- enabled = function()
- return getSpellColorsPageState(getRows()).canReconcile
- end,
- onClick = function()
- if getSpellColorsPageState(getRows()).canReconcile then
- reconcileSpellColors()
- end
- end,
- },
- {
- text = L["SPELL_COLORS_REMOVE_STALE_BUTTON"],
- width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
- tooltip = L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
- enabled = function()
- return getSpellColorsPageState(getRows()).canReconcile
- end,
- onClick = function()
- if getSpellColorsPageState(getRows()).canReconcile then
- removeStaleSpellColors()
- end
- end,
- },
- },
- },
- {
- id = "spellColorsDescription",
- type = "info",
- name = "",
- value = L["SPELL_COLORS_DESC"],
- wide = true,
- multiline = true,
- height = 36,
- },
- {
- id = "spellColorsWarning",
- type = "info",
- name = "",
- value = function()
- return getSpellColorsPageState(getRows()).warningText
- end,
- wide = true,
- multiline = true,
- height = 30,
- hidden = function()
- return getSpellColorsPageState(getRows()).warningText == ""
- end,
- },
- {
- id = "spellColorCollection",
- type = "list",
- variant = "swatch",
- height = 260,
- rowHeight = C.SCROLL_ROW_HEIGHT_COMPACT,
- items = buildSpellColorItems,
- onDefault = resetAllSpellColors,
- },
- {
- id = "secretNameDescription",
- type = "info",
- name = "",
- value = L["SPELL_COLORS_SECRET_NAMES_DESC"],
- wide = true,
- multiline = true,
- height = C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT,
- hidden = function()
- return not getSpellColorsPageState(getRows()).showSecretNameWarning
- end,
- },
- },
- }
- pageSpec.SetRegisteredPage = setRegisteredPage
- return pageSpec
-end
-
---------------------------------------------------------------------------------
--- Options Registration
---------------------------------------------------------------------------------
-
local BuffBarsOptions = {}
ns.BuffBarsOptions = BuffBarsOptions
-BuffBarsOptions._BuildSpellColorRows = buildSpellColorRows
-BuffBarsOptions._CollectIncompleteSpellColorRows = collectIncompleteSpellColorRows
-BuffBarsOptions._GetSpellColorsPageState = getSpellColorsPageState
-BuffBarsOptions._BuildSpellColorKeyTooltipLines = buildSpellColorKeyTooltipLines
-local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
+local SpellColorsPage = ns.SpellColorsPage
+local isBuffBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate(C.SCOPE_BUFFBARS)
+
+SpellColorsPage.RegisterSection({
+ key = C.SCOPE_BUFFBARS,
+ label = L["AURA_BARS"],
+ scope = C.SCOPE_BUFFBARS,
+ isDisabledDelegate = SpellColorsPage.CreateSectionDisabledDelegate(C.SCOPE_BUFFBARS, "BuffBars"),
+ ownerModuleName = "BuffBars",
+})
local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
local layoutMovedButton = ns.OptionUtil.CreateLayoutBreadcrumbArgs(10).layoutMovedButton
@@ -414,27 +42,27 @@ BuffBarsOptions.pages = {
layoutMovedButton,
-- Appearance
- { id = "appearanceHeader", type = "header", name = L["APPEARANCE"], disabled = isDisabled },
+ { id = "appearanceHeader", type = "header", name = L["APPEARANCE"], disabled = isBuffBarsDisabled },
{
id = "showIcon",
type = "checkbox",
path = "showIcon",
name = L["SHOW_ICON"],
- disabled = isDisabled,
+ disabled = isBuffBarsDisabled,
},
{
id = "showSpellName",
type = "checkbox",
path = "showSpellName",
name = L["SHOW_SPELL_NAME"],
- disabled = isDisabled,
+ disabled = isBuffBarsDisabled,
},
{
id = "showDuration",
type = "checkbox",
path = "showDuration",
name = L["SHOW_REMAINING_DURATION"],
- disabled = isDisabled,
+ disabled = isBuffBarsDisabled,
},
{
id = "height",
@@ -445,7 +73,7 @@ BuffBarsOptions.pages = {
min = 0,
max = 40,
step = 1,
- disabled = isDisabled,
+ disabled = isBuffBarsDisabled,
getTransform = defaultZero,
setTransform = function(value)
return value > 0 and value or nil
@@ -460,22 +88,19 @@ BuffBarsOptions.pages = {
min = 0,
max = 20,
step = 1,
- disabled = isDisabled,
+ disabled = isBuffBarsDisabled,
getTransform = defaultZero,
},
(function()
- local row = ns.OptionUtil.CreateFontOverrideRow(isDisabled)
+ local row = ns.OptionUtil.CreateFontOverrideRow(isBuffBarsDisabled)
row.id = "fontOverride"
return row
end)(),
},
},
- createSpellColorPage(L["SPELL_COLORS_SUBCAT"]),
}
-function BuffBarsOptions.SetSpellColorsPage(page)
- local spellColorsPage = BuffBarsOptions.pages[2]
- if spellColorsPage and spellColorsPage.SetRegisteredPage then
- spellColorsPage.SetRegisteredPage(page)
- end
-end
+BuffBarsOptions._BuildSpellColorRows = SpellColorsPage._BuildSpellColorRows
+BuffBarsOptions._CollectIncompleteSpellColorRows = SpellColorsPage._CollectIncompleteSpellColorRows
+BuffBarsOptions._GetSpellColorsPageState = SpellColorsPage._GetSpellColorsPageState
+BuffBarsOptions._BuildSpellColorKeyTooltipLines = SpellColorsPage._BuildSpellColorKeyTooltipLines
diff --git a/UI/ExternalBarsOptions.lua b/UI/ExternalBarsOptions.lua
new file mode 100644
index 00000000..e44cebf2
--- /dev/null
+++ b/UI/ExternalBarsOptions.lua
@@ -0,0 +1,107 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local C = ns.Constants
+local L = ns.L
+
+local ExternalBarsOptions = {}
+ns.ExternalBarsOptions = ExternalBarsOptions
+
+local SpellColorsPage = ns.SpellColorsPage
+local isModuleDisabled = ns.OptionUtil.GetIsDisabledDelegate(C.SCOPE_EXTERNALBARS)
+local defaultZero = ns.OptionUtil.CreateDefaultValueTransform(0)
+local layoutMovedButton = ns.OptionUtil.CreateLayoutBreadcrumbArgs(10).layoutMovedButton
+layoutMovedButton.id = "layoutMovedButton"
+
+SpellColorsPage.RegisterSection({
+ key = C.SCOPE_EXTERNALBARS,
+ label = L["EXTERNAL_BARS"],
+ scope = C.SCOPE_EXTERNALBARS,
+ isDisabledDelegate = SpellColorsPage.CreateSectionDisabledDelegate(C.SCOPE_EXTERNALBARS, "ExternalBars"),
+ ownerModuleName = "ExternalBars",
+})
+
+ExternalBarsOptions.key = "externalBars"
+ExternalBarsOptions.name = L["EXTERNAL_BARS"]
+ExternalBarsOptions.pages = {
+ {
+ key = "main",
+ rows = {
+ {
+ id = "enabled",
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_EXTERNAL_BARS"],
+ tooltip = L["ENABLE_EXTERNAL_BARS_DESC"],
+ onSet = ns.OptionUtil.CreateModuleEnabledHandler("ExternalBars", L["DISABLE_EXTERNAL_BARS_RELOAD"]),
+ },
+
+ layoutMovedButton,
+
+ { id = "appearanceHeader", type = "header", name = L["APPEARANCE"], disabled = isModuleDisabled },
+ {
+ id = "hideOriginalIcons",
+ type = "checkbox",
+ path = "hideOriginalIcons",
+ name = L["HIDE_ORIGINAL_ICONS"],
+ tooltip = L["HIDE_ORIGINAL_ICONS_DESC"],
+ disabled = isModuleDisabled,
+ },
+ {
+ id = "showIcon",
+ type = "checkbox",
+ path = "showIcon",
+ name = L["SHOW_ICON"],
+ disabled = isModuleDisabled,
+ },
+ {
+ id = "showSpellName",
+ type = "checkbox",
+ path = "showSpellName",
+ name = L["SHOW_SPELL_NAME"],
+ disabled = isModuleDisabled,
+ },
+ {
+ id = "showDuration",
+ type = "checkbox",
+ path = "showDuration",
+ name = L["SHOW_REMAINING_DURATION"],
+ disabled = isModuleDisabled,
+ },
+ {
+ id = "height",
+ type = "slider",
+ path = "height",
+ name = L["HEIGHT_OVERRIDE"],
+ tooltip = L["HEIGHT_OVERRIDE_DESC"],
+ min = 0,
+ max = 40,
+ step = 1,
+ disabled = isModuleDisabled,
+ getTransform = defaultZero,
+ setTransform = function(value)
+ return value > 0 and value or nil
+ end,
+ },
+ {
+ id = "verticalSpacing",
+ type = "slider",
+ path = "verticalSpacing",
+ name = L["AURA_VERTICAL_SPACING"],
+ tooltip = L["AURA_VERTICAL_SPACING_DESC"],
+ min = 0,
+ max = 20,
+ step = 1,
+ disabled = isModuleDisabled,
+ getTransform = defaultZero,
+ },
+ (function()
+ local row = ns.OptionUtil.CreateFontOverrideRow(isModuleDisabled)
+ row.id = "fontOverride"
+ return row
+ end)(),
+ },
+ },
+}
diff --git a/UI/LayoutOptions.lua b/UI/LayoutOptions.lua
index 2682a3f6..c8cda209 100644
--- a/UI/LayoutOptions.lua
+++ b/UI/LayoutOptions.lua
@@ -30,6 +30,7 @@ local powerBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("powerBar")
local resourceBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("resourceBar")
local runeBarDisabled = ns.OptionUtil.GetIsDisabledDelegate("runeBar")
local buffBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate("buffBars")
+local externalBarsDisabled = ns.OptionUtil.GetIsDisabledDelegate("externalBars")
local rows = {
{
@@ -45,6 +46,7 @@ local rows = {
createAnchorModeSpec(L["RESOURCE_BAR"], "resourceBar.anchorMode", resourceBarDisabled),
createAnchorModeSpec(L["RUNE_BAR"], "runeBar.anchorMode", runeBarDisabled),
createAnchorModeSpec(L["AURA_BARS"], "buffBars.anchorMode", buffBarsDisabled),
+ createAnchorModeSpec(L["EXTERNAL_BARS"], "externalBars.anchorMode", externalBarsDisabled),
{
type = "header",
name = L["POSITION_MODE_ATTACHED"],
diff --git a/UI/Options.lua b/UI/Options.lua
index 9a9a09ff..03546a54 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -152,19 +152,32 @@ function Options:InstallCategoryTracking()
end
function Options:OnInitialize()
+ local sections = {
+ ns.GeneralOptions,
+ ns.LayoutOptions,
+ ns.PowerBarOptions,
+ ns.ResourceBarOptions,
+ ns.RuneBarOptions,
+ ns.BuffBarsOptions,
+ ns.ExtraIconsOptions,
+ }
+
+ if ns.ExternalBarsOptions then
+ sections[#sections + 1] = ns.ExternalBarsOptions
+ end
+
+ sections[#sections + 1] = {
+ key = "spellColors",
+ name = L["SPELL_COLORS_SUBCAT"],
+ pages = { ns.SpellColorsPage.CreatePage(L["SPELL_COLORS_SUBCAT"]) },
+ }
+
+ sections[#sections + 1] = ns.ProfileOptions
+ sections[#sections + 1] = ns.AdvancedOptions
+
ns.Settings:_registerTree({
page = ns.AboutPage,
- sections = {
- ns.GeneralOptions,
- ns.LayoutOptions,
- ns.PowerBarOptions,
- ns.ResourceBarOptions,
- ns.RuneBarOptions,
- ns.BuffBarsOptions,
- ns.ExtraIconsOptions,
- ns.ProfileOptions,
- ns.AdvancedOptions,
- },
+ sections = sections,
})
if ns.ExtraIconsOptionsUtil then
@@ -174,8 +187,8 @@ function Options:OnInitialize()
if ns.PowerBarTickMarksOptions and ns.PowerBarTickMarksOptions.SetRegisteredPage then
ns.PowerBarTickMarksOptions.SetRegisteredPage(ns.Settings:GetPage("powerBar", "tickMarks"))
end
- if ns.BuffBarsOptions and ns.BuffBarsOptions.SetSpellColorsPage then
- ns.BuffBarsOptions.SetSpellColorsPage(ns.Settings:GetPage("buffBars", "spellColors"))
+ if ns.SpellColorsPage and ns.SpellColorsPage.SetRegisteredPage then
+ ns.SpellColorsPage.SetRegisteredPage(ns.Settings:GetPage("spellColors", "spellColors"))
end
self:InstallCategoryTracking()
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
new file mode 100644
index 00000000..a32e361d
--- /dev/null
+++ b/UI/SpellColorsPage.lua
@@ -0,0 +1,628 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local C = ns.Constants
+local L = ns.L
+
+local REMOVE_STALE_SPELL_COLORS_POPUP = "ECM_CONFIRM_REMOVE_STALE_SPELL_COLORS"
+local SPELL_COLORS_HEADER_BUTTON_WIDTH = 100
+
+local SpellColorsPage = ns.SpellColorsPage or {}
+ns.SpellColorsPage = SpellColorsPage
+
+local spellColorSections = SpellColorsPage._sections or {}
+SpellColorsPage._sections = spellColorSections
+
+local pageSpec = SpellColorsPage._pageSpec
+local registeredPage = SpellColorsPage._registeredPage
+
+---@param scope string|nil
+---@return ECM_SpellColorStore
+local function getSpellColors(scope)
+ return ns.SpellColors.Get(scope)
+end
+
+---@param scope string|nil
+---@return ECM_Color
+local function getScopeDefaultColor(scope)
+ local defaults = ns.defaults and ns.defaults.profile and ns.defaults.profile[scope]
+ local color = defaults and defaults.colors and defaults.colors.defaultColor
+ return color or C.BUFFBARS_DEFAULT_COLOR
+end
+
+---@param entries { key: ECM_SpellColorKey }[]|nil
+---@param scope string|nil
+---@return { key: ECM_SpellColorKey, textureFileID: number|nil }[]
+local function buildSpellColorRows(entries, scope)
+ local rows = {}
+ local resolvedEntries = entries or getSpellColors(scope):GetAllColorEntries()
+
+ for _, entry in ipairs(resolvedEntries or {}) do
+ local normalized = ns.SpellColors.NormalizeKey(type(entry) == "table" and entry.key)
+ if normalized then
+ local merged = false
+ for _, row in ipairs(rows) do
+ if row.key:Matches(normalized) then
+ row.key = row.key:Merge(normalized) or row.key
+ row.textureFileID = row.key.textureFileID or row.textureFileID
+ merged = true
+ break
+ end
+ end
+
+ if not merged then
+ rows[#rows + 1] = {
+ key = normalized,
+ textureFileID = normalized.textureFileID,
+ }
+ end
+ end
+ end
+
+ return rows
+end
+
+---@param key ECM_SpellColorKey|table|nil
+---@param _scope string|nil
+---@return { hasSecretName: boolean, isIncomplete: boolean }|nil
+local function getSpellColorKeyState(key, _scope)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ local primaryKey = normalized and normalized.primaryKey or (type(key) == "table" and key.primaryKey)
+ if not normalized and type(primaryKey) ~= "string" then
+ return nil
+ end
+
+ return {
+ hasSecretName = type(primaryKey) == "string" and (issecretvalue(primaryKey) or primaryKey == ""),
+ isIncomplete = normalized ~= nil and (normalized.spellName == nil
+ or normalized.spellID == nil
+ or normalized.cooldownID == nil
+ or normalized.textureFileID == nil),
+ }
+end
+
+---@return boolean
+local function isSpellColorsReconcileRestricted()
+ return _G.UnitAffectingCombat("player") or InCombatLockdown() or IsInInstance()
+end
+
+---@param rows { key: ECM_SpellColorKey }[]|nil
+---@param scope string|nil
+---@return { hasRowsNeedingReconcile: boolean, showSecretNameWarning: boolean, warningText: string, canReconcile: boolean }
+local function getSpellColorsPageState(rows, scope)
+ local state = {
+ hasRowsNeedingReconcile = false,
+ showSecretNameWarning = false,
+ warningText = "",
+ canReconcile = false,
+ }
+ local resolvedRows = rows or buildSpellColorRows(nil, scope)
+
+ for _, row in ipairs(resolvedRows or {}) do
+ local keyState = getSpellColorKeyState(row and row.key, scope)
+ if keyState then
+ state.hasRowsNeedingReconcile = state.hasRowsNeedingReconcile or keyState.isIncomplete
+ state.showSecretNameWarning = state.showSecretNameWarning or keyState.hasSecretName
+
+ if state.hasRowsNeedingReconcile and state.showSecretNameWarning then
+ break
+ end
+ end
+ end
+
+ if InCombatLockdown() then
+ state.warningText = L["SPELL_COLORS_COMBAT_WARNING"]
+ end
+
+ state.canReconcile = state.hasRowsNeedingReconcile and not isSpellColorsReconcileRestricted()
+
+ return state
+end
+
+---@param rows { key: ECM_SpellColorKey }[]|nil
+---@param scope string|nil
+---@return { key: ECM_SpellColorKey }[]
+local function collectIncompleteSpellColorRows(rows, scope)
+ local incompleteRows = {}
+ local resolvedRows = rows or buildSpellColorRows(nil, scope)
+
+ for _, row in ipairs(resolvedRows or {}) do
+ local keyState = getSpellColorKeyState(row and row.key, scope)
+ if keyState and keyState.isIncomplete then
+ incompleteRows[#incompleteRows + 1] = row
+ end
+ end
+
+ return incompleteRows
+end
+
+---@param key ECM_SpellColorKey|table|nil
+---@param _scope string|nil
+---@return string
+local function getSpellColorRowName(key, _scope)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ local primaryKey = normalized and normalized.primaryKey or nil
+
+ if type(primaryKey) == "string" then
+ return primaryKey
+ end
+
+ return "Bar (" .. tostring(primaryKey) .. ")"
+end
+
+---@param key ECM_SpellColorKey|table|nil
+---@param _scope string|nil
+---@return string[]
+local function buildSpellColorKeyTooltipLines(key, _scope)
+ local normalized = ns.SpellColors.NormalizeKey(key)
+ if not normalized then
+ return {}
+ end
+
+ local lines = {}
+
+ local function addLine(formatString, value)
+ local valueType = type(value)
+ if valueType == "string" or valueType == "number" then
+ lines[#lines + 1] = string.format(formatString, value)
+ end
+ end
+
+ addLine(L["SPELL_COLORS_KEY_SPELL_NAME"], normalized.spellName)
+ addLine(L["SPELL_COLORS_KEY_SPELL_ID"], normalized.spellID)
+ addLine(L["SPELL_COLORS_KEY_COOLDOWN_ID"], normalized.cooldownID)
+ addLine(L["SPELL_COLORS_KEY_TEXTURE_FILE_ID"], normalized.textureFileID)
+
+ return lines
+end
+
+---@param owner Frame
+---@param data { key: ECM_SpellColorKey }|nil
+---@param scope string|nil
+local function maybeShowSpellColorKeyTooltip(owner, data, scope)
+ if not IsControlKeyDown() then
+ return
+ end
+
+ local lines = buildSpellColorKeyTooltipLines(type(data) == "table" and data.key, scope)
+ if #lines == 0 then
+ return
+ end
+
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(L["SPELL_COLORS_KEYS_TOOLTIP_TITLE"], 1, 1, 1, 1)
+
+ for _, line in ipairs(lines) do
+ GameTooltip:AddLine(line, 1, 1, 1, true)
+ end
+
+ GameTooltip:Show()
+end
+
+---@param refreshPage fun()
+local function refreshSpellColors(refreshPage)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ refreshPage()
+end
+
+---@param section table
+---@return boolean
+local function isSpellColorSectionDisabled(section)
+ return section.isDisabledDelegate and section.isDisabledDelegate() or false
+end
+
+---@param section table
+---@return table|nil
+local function getSpellColorOwnerModule(section)
+ return ns.Addon and section and section.ownerModuleName and ns.Addon[section.ownerModuleName] or nil
+end
+
+---@param section table
+---@return boolean
+local function isSpellColorSectionEditLocked(section)
+ local ownerModule = getSpellColorOwnerModule(section)
+ if ownerModule and ownerModule.IsEditLocked then
+ return ownerModule:IsEditLocked()
+ end
+ return false
+end
+
+---@param section table
+---@return boolean
+local function isSpellColorSectionInteractionDisabled(section)
+ return isSpellColorSectionDisabled(section) or isSpellColorSectionEditLocked(section)
+end
+
+---@param section table
+---@return { key: ECM_SpellColorKey, textureFileID: number|nil }[]
+local function getSectionSpellColorRows(section)
+ return buildSpellColorRows(nil, section.scope)
+end
+
+---@param section table
+---@return { hasRowsNeedingReconcile: boolean, showSecretNameWarning: boolean, warningText: string, canReconcile: boolean }
+local function getSectionSpellColorPageState(section)
+ return getSpellColorsPageState(nil, section.scope)
+end
+
+---@param refreshPage fun()|nil
+local function doRefreshPage(refreshPage)
+ if refreshPage then
+ refreshPage()
+ return
+ end
+
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+end
+
+local combatRefreshCallback
+
+---@param page Frame|table|nil
+function SpellColorsPage.SetRegisteredPage(page)
+ registeredPage = page
+ SpellColorsPage._registeredPage = page
+
+ if combatRefreshCallback then
+ return
+ end
+
+ local addon = ns.Addon
+ if not addon or type(addon.RegisterEvent) ~= "function" then
+ return
+ end
+
+ combatRefreshCallback = function()
+ doRefreshPage()
+ end
+
+ addon:RegisterEvent("PLAYER_REGEN_DISABLED", combatRefreshCallback)
+ addon:RegisterEvent("PLAYER_REGEN_ENABLED", combatRefreshCallback)
+end
+
+---@param section table
+---@param refreshPage fun()
+local function resetSpellColors(section, refreshPage)
+ local spellColors = getSpellColors(section.scope)
+ spellColors:ClearCurrentSpecColors()
+ spellColors:SetDefaultColor(getScopeDefaultColor(section.scope))
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+end
+
+local function reconcileSpellColors()
+ ns.Addon:ConfirmReloadUI(L["SPELL_COLORS_SECRET_NAMES_DESC"])
+end
+
+---@param section table
+---@param refreshPage fun()
+local function removeStaleSpellColors(section, refreshPage)
+ local staleRows = collectIncompleteSpellColorRows(nil, section.scope)
+ if #staleRows == 0 then
+ return
+ end
+
+ local spellColors = getSpellColors(section.scope)
+
+ ns.Addon:ShowConfirmDialog(
+ REMOVE_STALE_SPELL_COLORS_POPUP,
+ L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
+ L["REMOVE"],
+ L["SPELL_COLORS_DONT_REMOVE"],
+ function()
+ local staleKeys = {}
+ for _, row in ipairs(staleRows) do
+ staleKeys[#staleKeys + 1] = row.key
+ end
+
+ local removedKeys = spellColors:RemoveEntriesByKeys(staleKeys)
+ for _, key in ipairs(removedKeys) do
+ ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key, section.scope)))
+ end
+
+ if #removedKeys > 0 then
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+ end
+ end
+ )
+end
+
+---@param section table
+---@param refreshPage fun()
+---@return table[]
+local function buildSpellColorItems(section, refreshPage)
+ local items = {}
+ local rows = getSectionSpellColorRows(section)
+ local spellColors = getSpellColors(section.scope)
+
+ local function isInteractionDisabled()
+ return isSpellColorSectionInteractionDisabled(section)
+ end
+
+ local function decorateItem(item)
+ if isSpellColorSectionDisabled(section) then
+ item.alpha = 0.5
+ item.iconDesaturated = true
+ end
+ return item
+ end
+
+ items[#items + 1] = decorateItem({
+ label = L["DEFAULT_COLOR"],
+ color = {
+ value = spellColors:GetDefaultColor(),
+ enabled = function()
+ return not isInteractionDisabled()
+ end,
+ onClick = function()
+ if isInteractionDisabled() then
+ return
+ end
+
+ ns.OptionUtil.OpenColorPicker(spellColors:GetDefaultColor(), false, function(color)
+ spellColors:SetDefaultColor(color)
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+ end)
+ end,
+ },
+ })
+
+ for _, row in ipairs(rows) do
+ items[#items + 1] = decorateItem({
+ label = getSpellColorRowName(row.key, section.scope),
+ icon = row.textureFileID,
+ color = {
+ value = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor(),
+ enabled = function()
+ return not isInteractionDisabled()
+ end,
+ onClick = function()
+ if isInteractionDisabled() then
+ return
+ end
+
+ local current = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor()
+ ns.OptionUtil.OpenColorPicker(current, false, function(color)
+ spellColors:SetColorByKey(row.key, color)
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+ end)
+ end,
+ },
+ onEnter = function(owner)
+ maybeShowSpellColorKeyTooltip(owner, row, section.scope)
+ end,
+ onLeave = function()
+ GameTooltip_Hide()
+ end,
+ })
+ end
+
+ return items
+end
+
+---@param section table
+---@param refreshPage fun()
+---@return table
+local function createSpellColorSectionHeaderRow(section, refreshPage)
+ return {
+ id = section.key .. "SpellColorsPageActions",
+ type = "pageActions",
+ name = section.label,
+ attachToCategoryHeader = false,
+ hideTitle = false,
+ height = 28,
+ disabled = section.isDisabledDelegate,
+ actions = {
+ {
+ text = L["SPELL_COLORS_RECONCILE_BUTTON"],
+ width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
+ enabled = function()
+ return not isSpellColorSectionInteractionDisabled(section)
+ and getSectionSpellColorPageState(section).canReconcile
+ end,
+ onClick = function()
+ if isSpellColorSectionInteractionDisabled(section)
+ or not getSectionSpellColorPageState(section).canReconcile then
+ return
+ end
+
+ reconcileSpellColors()
+ end,
+ },
+ {
+ text = L["SPELL_COLORS_REMOVE_STALE_BUTTON"],
+ width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
+ tooltip = L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
+ enabled = function()
+ return not isSpellColorSectionInteractionDisabled(section)
+ and getSectionSpellColorPageState(section).canReconcile
+ end,
+ onClick = function()
+ if isSpellColorSectionInteractionDisabled(section)
+ or not getSectionSpellColorPageState(section).canReconcile then
+ return
+ end
+
+ removeStaleSpellColors(section, refreshPage)
+ end,
+ },
+ {
+ text = L["RESET"],
+ width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
+ enabled = function()
+ return not isSpellColorSectionInteractionDisabled(section)
+ end,
+ onClick = function()
+ if isSpellColorSectionInteractionDisabled(section) then
+ return
+ end
+
+ resetSpellColors(section, refreshPage)
+ end,
+ },
+ },
+ }
+end
+
+---@param section table
+---@return table
+local function createSpellColorWarningRow(section)
+ return {
+ id = section.key .. "SpellColorsWarning",
+ type = "info",
+ name = "",
+ value = function()
+ return getSectionSpellColorPageState(section).warningText
+ end,
+ wide = true,
+ multiline = true,
+ height = 30,
+ hidden = function()
+ return getSectionSpellColorPageState(section).warningText == ""
+ end,
+ }
+end
+
+---@param section table
+---@param refreshPage fun()
+---@return table
+local function createSpellColorListRow(section, refreshPage)
+ return {
+ id = section.key .. "SpellColorCollection",
+ type = "list",
+ variant = "swatch",
+ height = 260,
+ rowHeight = C.SCROLL_ROW_HEIGHT_COMPACT,
+ items = function()
+ return buildSpellColorItems(section, refreshPage)
+ end,
+ }
+end
+
+---@param section table
+---@return table
+local function createSecretNameDescriptionRow(section)
+ return {
+ id = section.key .. "SecretNameDescription",
+ type = "info",
+ name = "",
+ value = L["SPELL_COLORS_SECRET_NAMES_DESC"],
+ wide = true,
+ multiline = true,
+ height = C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT,
+ hidden = function()
+ return not getSectionSpellColorPageState(section).showSecretNameWarning
+ end,
+ }
+end
+
+local function buildPageRows()
+ local rows = {}
+
+ local function refreshPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+ end
+
+ for index, section in ipairs(spellColorSections) do
+ rows[#rows + 1] = createSpellColorSectionHeaderRow(section, refreshPage)
+
+ if index == 1 then
+ rows[#rows + 1] = {
+ id = "spellColorsDescription",
+ type = "info",
+ name = "",
+ value = L["SPELL_COLORS_DESC"],
+ wide = true,
+ multiline = true,
+ height = 36,
+ }
+ end
+
+ rows[#rows + 1] = createSpellColorWarningRow(section)
+ rows[#rows + 1] = createSpellColorListRow(section, refreshPage)
+ rows[#rows + 1] = createSecretNameDescriptionRow(section)
+ end
+
+ return rows
+end
+
+local function rebuildPageSpecRows()
+ if pageSpec then
+ pageSpec.rows = buildPageRows()
+ end
+end
+
+---@param section { key: string, label: string, scope: string, isDisabledDelegate: fun(): boolean, ownerModuleName: string }
+function SpellColorsPage.RegisterSection(section)
+ assert(type(section) == "table", "SpellColorsPage.RegisterSection: section must be a table")
+ assert(type(section.key) == "string", "SpellColorsPage.RegisterSection: section.key is required")
+ assert(type(section.label) == "string", "SpellColorsPage.RegisterSection: section.label is required")
+ assert(type(section.scope) == "string", "SpellColorsPage.RegisterSection: section.scope is required")
+ assert(type(section.ownerModuleName) == "string", "SpellColorsPage.RegisterSection: ownerModuleName is required")
+
+ for index, existing in ipairs(spellColorSections) do
+ if existing.key == section.key then
+ spellColorSections[index] = section
+ rebuildPageSpecRows()
+ return section
+ end
+ end
+
+ spellColorSections[#spellColorSections + 1] = section
+ rebuildPageSpecRows()
+ return section
+end
+
+---@param configPath string
+---@param ownerModuleName string
+---@return fun(): boolean
+function SpellColorsPage.CreateSectionDisabledDelegate(configPath, ownerModuleName)
+ local isDisabled = ns.OptionUtil.GetIsDisabledDelegate(configPath)
+
+ return function()
+ local ownerModule = ns.Addon and ns.Addon[ownerModuleName] or nil
+ if not ownerModule then
+ return true
+ end
+
+ return isDisabled()
+ end
+end
+
+---@param subcatName string
+---@return table
+function SpellColorsPage.CreatePage(subcatName)
+ if not pageSpec then
+ pageSpec = {
+ key = "spellColors",
+ name = subcatName,
+ rows = {},
+ }
+ pageSpec.SetRegisteredPage = SpellColorsPage.SetRegisteredPage
+ SpellColorsPage._pageSpec = pageSpec
+ end
+
+ pageSpec.name = subcatName
+ pageSpec.rows = buildPageRows()
+ return pageSpec
+end
+
+SpellColorsPage._BuildSpellColorRows = buildSpellColorRows
+SpellColorsPage._CollectIncompleteSpellColorRows = collectIncompleteSpellColorRows
+SpellColorsPage._GetSpellColorsPageState = getSpellColorsPageState
+SpellColorsPage._BuildSpellColorKeyTooltipLines = buildSpellColorKeyTooltipLines
From e3f81a403a14533f147b18f4576465c98e2be87d Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 20 Apr 2026 08:22:54 +1000
Subject: [PATCH 26/53] Add diagnostics for external bars. Combine actions on
the spell colours page.
---
Modules/ExternalBars.lua | 77 +++++++++--
Runtime.lua | 35 ++---
Tests/ECM_Runtime_spec.lua | 61 ++++-----
Tests/Modules/ExternalBars_spec.lua | 108 ++++++++++++---
Tests/UI/BuffBarsOptions_spec.lua | 110 ++++++++++-----
Tests/UI/ExternalBarsOptions_spec.lua | 10 +-
UI/SpellColorsPage.lua | 190 +++++++++++++++++---------
7 files changed, 401 insertions(+), 190 deletions(-)
diff --git a/Modules/ExternalBars.lua b/Modules/ExternalBars.lua
index afc7e1ef..6063e5a0 100644
--- a/Modules/ExternalBars.lua
+++ b/Modules/ExternalBars.lua
@@ -149,28 +149,23 @@ function ExternalBars:_SetOriginalIconsHidden(hidden)
end
if hidden then
+ if self._originalIconsHidden then
+ return
+ end
+
self._originalIconsHidden = true
viewer:SetAlpha(0)
viewer:EnableMouse(false)
return
end
- if self._originalIconsHidden then
- local alpha = 1
-
- self._originalIconsHidden = nil
-
- if ns.Runtime and ns.Runtime.GetDesiredAlpha then
- alpha = ns.Runtime.GetDesiredAlpha()
- end
-
- viewer:SetAlpha(alpha)
- viewer:EnableMouse(alpha > 0)
-
- if ns.Runtime and ns.Runtime.RequestLayout then
- ns.Runtime.RequestLayout("ExternalBars:OriginalIconsShown")
- end
+ if not self._originalIconsHidden then
+ return
end
+
+ self._originalIconsHidden = nil
+ viewer:SetAlpha(1)
+ viewer:EnableMouse(true)
end
function ExternalBars:_RefreshOriginalIconsState()
@@ -467,6 +462,8 @@ function ExternalBars:OnExternalAurasUpdated()
local auraInfo = viewer and viewer.auraInfo or nil
local auraStates = self._auraStates or {}
self._auraStates = auraStates
+ local debugEnabled = ns.IsDebugEnabled()
+ local auraDiagnostics = debugEnabled and {} or nil
local activeAuraCount = 0
if type(auraInfo) == "table" then
@@ -513,10 +510,38 @@ function ExternalBars:OnExternalAurasUpdated()
and duration > 0
and type(expirationTime) == "number"
auraState.hasRenderableDuration = durationIsSecret or (type(duration) == "number" and duration > 0)
+
+ if auraDiagnostics then
+ auraDiagnostics[#auraDiagnostics + 1] = {
+ index = index,
+ auraInstanceID = auraInstanceID,
+ name = auraName,
+ spellID = spellID,
+ texture = info.texture,
+ hasAuraData = accessibleAuraData ~= nil,
+ durationIsSecret = durationIsSecret,
+ expirationTimeIsSecret = expirationTimeIsSecret,
+ canShowDurationText = auraState.canShowDurationText,
+ hasRenderableDuration = auraState.hasRenderableDuration,
+ }
+ end
end
end
self._activeAuraCount = activeAuraCount
+
+ if debugEnabled then
+ ns.Log(self.Name, "OnExternalAurasUpdated", {
+ viewerShown = viewer ~= nil and viewer:IsShown() or false,
+ viewerAlpha = viewer and viewer.GetAlpha and viewer:GetAlpha() or nil,
+ hideOriginalIcons = self._originalIconsHidden == true,
+ runtimeOriginalIconsHidden = self._originalIconsHidden == true,
+ auraCount = activeAuraCount,
+ auraInfoType = type(auraInfo),
+ auras = auraDiagnostics,
+ })
+ end
+
ns.Runtime.RequestLayout("ExternalBars:UpdateAuras")
end
@@ -524,7 +549,15 @@ end
---@return boolean
function ExternalBars:UpdateLayout(why)
local frame = self.InnerFrame
+ local debugEnabled = ns.IsDebugEnabled()
if not frame then
+ if debugEnabled then
+ ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
+ applied = false,
+ reason = "no-frame",
+ activeAuraCount = self._activeAuraCount or 0,
+ })
+ end
return false
end
@@ -544,6 +577,13 @@ function ExternalBars:UpdateLayout(why)
if not params then
self:_hideExcessBars(0)
self:_StopDurationTicker()
+ if debugEnabled then
+ ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
+ applied = false,
+ reason = "no-position",
+ activeAuraCount = self._activeAuraCount or 0,
+ })
+ end
return false
end
@@ -598,8 +638,15 @@ function ExternalBars:UpdateLayout(why)
self:_RestartDurationTicker()
+ local viewer = getViewer()
ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
+ activeAuraCount = activeAuraCount,
barCount = barCount,
+ viewerShown = viewer ~= nil and viewer:IsShown() or false,
+ viewerAlpha = viewer and viewer.GetAlpha and viewer:GetAlpha() or nil,
+ hideOriginalIcons = self._originalIconsHidden == true,
+ runtimeOriginalIconsHidden = self._originalIconsHidden == true,
+ editLocked = self._editLocked == true,
anchorPoint = params.anchorPoint,
offsetX = params.offsetX,
offsetY = params.offsetY,
diff --git a/Runtime.lua b/Runtime.lua
index 6b6af7be..642e6992 100644
--- a/Runtime.lua
+++ b/Runtime.lua
@@ -57,20 +57,27 @@ end
local _detachedAnchor = nil
local _detachedAnchorMetrics = nil
+--- Applies the current Runtime-owned visibility and alpha to one
+--- Blizzard-managed cooldown viewer frame.
+---@param frame Frame
+local function applyBlizzardFrameState(frame)
+ if _globallyHidden then
+ if frame:IsShown() then frame:Hide() end
+ return
+ end
+
+ if not frame:IsShown() then frame:Show() end
+ ns.FrameUtil.LazySetAlpha(frame, _desiredAlpha)
+end
+
--- Enforces the current desired visibility and alpha on all Blizzard frames.
--- Single enforcement point called from state changes, OnShow hooks, and the
--- watchdog ticker.
local function enforceBlizzardFrameState()
- local alpha = _desiredAlpha
for _, name in ipairs(C.BLIZZARD_FRAMES) do
local frame = _G[name]
if frame then
- if _globallyHidden then
- if frame:IsShown() then frame:Hide() end
- else
- if not frame:IsShown() then frame:Show() end
- ns.FrameUtil.LazySetAlpha(frame, alpha)
- end
+ applyBlizzardFrameState(frame)
end
end
end
@@ -85,11 +92,7 @@ local function hookBlizzardFrame(frame, name)
end
frame:HookScript("OnShow", function(self)
- if _globallyHidden then
- self:Hide()
- else
- ns.FrameUtil.LazySetAlpha(self, _desiredAlpha)
- end
+ applyBlizzardFrameState(self)
end)
_hookedBlizzardFrames[name] = true
@@ -417,14 +420,6 @@ function Runtime.SetLayoutPreview(active)
Runtime.ScheduleLayoutUpdate(0, active and "LayoutPreviewOn" or "LayoutPreviewOff")
end
---- Returns the current alpha chosen by Runtime's fade logic.
---- External viewers that are not enforced directly by Runtime can use this
---- to restore themselves without hardcoding full opacity.
----@return number
-function Runtime.GetDesiredAlpha()
- return _desiredAlpha
-end
-
--- Shared layout execution: hooks deferred frames, updates visibility, runs layout.
local function executeLayout(reason)
_layoutPending = false
diff --git a/Tests/ECM_Runtime_spec.lua b/Tests/ECM_Runtime_spec.lua
index 432cfab5..4a50e4ed 100644
--- a/Tests/ECM_Runtime_spec.lua
+++ b/Tests/ECM_Runtime_spec.lua
@@ -123,6 +123,20 @@ describe("ECM.Runtime layout system", function()
}
end
+ local function makeBlizzardFrame(name)
+ local frame = makeFrame({ name = name })
+ frame._hookScriptCalls = 0
+
+ local origHookScript = frame.HookScript
+ function frame:HookScript(...)
+ self._hookScriptCalls = self._hookScriptCalls + 1
+ return origHookScript(self, ...)
+ end
+
+ _G[name] = frame
+ return frame
+ end
+
local CAPTURED_GLOBALS = {
"LibStub",
"C_Timer",
@@ -382,15 +396,6 @@ describe("ECM.Runtime layout system", function()
assert.are.equal(0.5, mod.InnerFrame:GetAlpha())
end)
- it("reports the current desired alpha for external viewer restores", function()
- makeRegisteredModule()
- _G._testDB.profile.global.outOfCombatFade = makeFadeConfig(50)
-
- ns.Runtime.ScheduleLayoutUpdate(0, "fade")
-
- assert.are.equal(0.5, ns.Runtime.GetDesiredAlpha())
- end)
-
it("does not fade in a delve when instance exceptions are enabled", function()
local mod = makeRegisteredModule()
local fadeConfig = makeFadeConfig(50)
@@ -880,20 +885,7 @@ describe("ECM.Runtime layout system", function()
end)
describe("watchdog graceful degradation", function()
- --- Creates a Blizzard frame stub in _G with HookScript call tracking.
- local function makeBlizzardFrame(name)
- local frame = makeFrame({ name = name })
- frame._hookScriptCalls = 0
- local origHookScript = frame.HookScript
- function frame:HookScript(...)
- self._hookScriptCalls = self._hookScriptCalls + 1
- return origHookScript(self, ...)
- end
- _G[name] = frame
- return frame
- end
-
- --- Places all 4 Blizzard frames + CooldownViewerSettings in _G.
+ --- Places all Blizzard frames and CooldownViewerSettings in _G.
local function createAllBlizzardFrames()
local frames = {}
for _, name in ipairs(ns.Constants.BLIZZARD_FRAMES) do
@@ -916,10 +908,11 @@ describe("ECM.Runtime layout system", function()
ns.Runtime.Enable(fakeAddon)
local ticker = createdTickers[1]
+ local allNames = ns.Constants.BLIZZARD_FRAMES
-- First tick: hooks all frames + settings
ticker.callback()
- for _, name in ipairs(ns.Constants.BLIZZARD_FRAMES) do
+ for _, name in ipairs(allNames) do
assert.are.equal(1, frames[name]._hookScriptCalls,
name .. " should be hooked exactly once after first tick")
end
@@ -928,7 +921,7 @@ describe("ECM.Runtime layout system", function()
-- Second tick: setup should be skipped
ticker.callback()
- for _, name in ipairs(ns.Constants.BLIZZARD_FRAMES) do
+ for _, name in ipairs(allNames) do
assert.are.equal(1, frames[name]._hookScriptCalls,
name .. " should still be hooked exactly once after second tick")
end
@@ -954,19 +947,24 @@ describe("ECM.Runtime layout system", function()
end)
it("continues setup when frames appear late", function()
- -- Start with only 2 of 4 Blizzard frames
+ -- Start with only 2 Blizzard frames.
+ local allNames = ns.Constants.BLIZZARD_FRAMES
local firstTwo = {}
+ local remainingNames = {}
for i = 1, 2 do
- local name = ns.Constants.BLIZZARD_FRAMES[i]
+ local name = allNames[i]
firstTwo[name] = makeBlizzardFrame(name)
end
+ for i = 3, #allNames do
+ remainingNames[#remainingNames + 1] = allNames[i]
+ end
ns.Runtime.Enable(fakeAddon)
local ticker = createdTickers[1]
-- First tick: hooks 2 frames, but no CooldownViewerSettings yet
ticker.callback()
- for _, name in ipairs({ ns.Constants.BLIZZARD_FRAMES[1], ns.Constants.BLIZZARD_FRAMES[2] }) do
+ for _, name in ipairs({ allNames[1], allNames[2] }) do
assert.are.equal(1, firstTwo[name]._hookScriptCalls)
end
@@ -975,8 +973,7 @@ describe("ECM.Runtime layout system", function()
-- Now add remaining frames + settings
local laterFrames = {}
- for i = 3, 4 do
- local name = ns.Constants.BLIZZARD_FRAMES[i]
+ for _, name in ipairs(remainingNames) do
laterFrames[name] = makeBlizzardFrame(name)
end
local settings = makeFrame({ name = "CooldownViewerSettings" })
@@ -990,7 +987,7 @@ describe("ECM.Runtime layout system", function()
-- Third tick: hooks remaining frames + settings, setup completes
ticker.callback()
- for _, name in ipairs({ ns.Constants.BLIZZARD_FRAMES[3], ns.Constants.BLIZZARD_FRAMES[4] }) do
+ for _, name in ipairs(remainingNames) do
assert.are.equal(1, laterFrames[name]._hookScriptCalls,
name .. " should be hooked on third tick")
end
@@ -998,7 +995,7 @@ describe("ECM.Runtime layout system", function()
-- Fourth tick: setup skipped, no additional hooks
ticker.callback()
- for _, name in ipairs({ ns.Constants.BLIZZARD_FRAMES[3], ns.Constants.BLIZZARD_FRAMES[4] }) do
+ for _, name in ipairs(remainingNames) do
assert.are.equal(1, laterFrames[name]._hookScriptCalls,
name .. " should not be re-hooked after setup complete")
end
diff --git a/Tests/Modules/ExternalBars_spec.lua b/Tests/Modules/ExternalBars_spec.lua
index da3f0e7c..69eb4cc7 100644
--- a/Tests/Modules/ExternalBars_spec.lua
+++ b/Tests/Modules/ExternalBars_spec.lua
@@ -24,7 +24,8 @@ describe("ExternalBars real source", function()
local colorLookupScopes
local discoveredScopes
local spellColorStores
- local runtimeAlpha
+ local debugLoggingEnabled
+ local logCalls
local makeFrame = TestHelpers.makeFrame
local makeHookableFrame = TestHelpers.makeHookableFrame
@@ -284,6 +285,17 @@ describe("ExternalBars real source", function()
return ExternalBars:UpdateLayout(reason or "test")
end
+ local function findLogCall(message)
+ for index = #logCalls, 1, -1 do
+ local entry = logCalls[index]
+ if entry.message == message then
+ return entry
+ end
+ end
+
+ return nil
+ end
+
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
"UIParent",
@@ -318,13 +330,13 @@ describe("ExternalBars real source", function()
colorLookupScopes = {}
discoveredScopes = {}
spellColorStores = {}
- runtimeAlpha = 1
+ debugLoggingEnabled = false
+ logCalls = {}
ns = {
- Log = function() end,
DebugAssert = function() end,
IsDebugEnabled = function()
- return false
+ return debugLoggingEnabled
end,
ToString = tostring,
Runtime = {
@@ -334,9 +346,6 @@ describe("ExternalBars real source", function()
UnregisterFrame = function()
unregisterFrameCalls = unregisterFrameCalls + 1
end,
- GetDesiredAlpha = function()
- return runtimeAlpha
- end,
RequestLayout = function(reason)
requestLayoutReasons[#requestLayoutReasons + 1] = reason
end,
@@ -418,6 +427,13 @@ describe("ExternalBars real source", function()
end,
},
}
+ ns.Log = function(scope, message, payload)
+ logCalls[#logCalls + 1] = {
+ scope = scope,
+ message = message,
+ payload = payload,
+ }
+ end
TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
@@ -590,6 +606,43 @@ describe("ExternalBars real source", function()
assert.are.equal(1, #durationTickers)
end)
+ it("emits detailed aura diagnostics when debug logging is enabled", function()
+ debugLoggingEnabled = true
+
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1.5,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ })
+
+ ExternalBars:OnExternalAurasUpdated()
+
+ local logEntry = assert(findLogCall("OnExternalAurasUpdated"))
+
+ assert.are.equal("ExternalBars", logEntry.scope)
+ assert.are.equal(1, logEntry.payload.auraCount)
+ assert.is_true(logEntry.payload.viewerShown)
+ assert.are.equal("table", logEntry.payload.auraInfoType)
+ assert.are.equal(1, #logEntry.payload.auras)
+ assert.same({
+ index = 1,
+ auraInstanceID = 11,
+ name = "Ironbark",
+ spellID = 102342,
+ texture = 5011,
+ hasAuraData = true,
+ durationIsSecret = false,
+ expirationTimeIsSecret = false,
+ canShowDurationText = true,
+ hasRenderableDuration = true,
+ }, logEntry.payload.auras[1])
+ end)
+
it("hides duration text but still configures cooldown and schedules the all-secret color retry path", function()
_G.issecretvalue = function()
return true
@@ -671,7 +724,7 @@ describe("ExternalBars real source", function()
assert.are.equal(3, #ExternalBars._barPool)
end)
- it("hides the original icons on enable and restores them on disable", function()
+ it("hides the original icons on enable and restores the viewer on disable", function()
profile.externalBars.hideOriginalIcons = true
ExternalBars:OnEnable()
@@ -691,16 +744,16 @@ describe("ExternalBars real source", function()
assert.are.equal(1, unregisterFrameCalls)
assert.are.equal(1, viewer:GetAlpha())
assert.is_true(viewer:IsMouseEnabled())
- assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ assert.same({}, requestLayoutReasons)
end)
- it("restores the viewer alpha when showing the original icons again", function()
+ it("restores viewer alpha and mouse directly when original icons are shown again", function()
local setAlphaCalls = {}
+ local enableMouseCalls = {}
profile.externalBars.hideOriginalIcons = true
ExternalBars:_RefreshOriginalIconsState()
requestLayoutReasons = {}
- runtimeAlpha = 0.35
local originalSetAlpha = viewer.SetAlpha
function viewer:SetAlpha(alpha)
@@ -708,27 +761,42 @@ describe("ExternalBars real source", function()
originalSetAlpha(self, alpha)
end
+ local originalEnableMouse = viewer.EnableMouse
+ function viewer:EnableMouse(enabled)
+ enableMouseCalls[#enableMouseCalls + 1] = enabled
+ originalEnableMouse(self, enabled)
+ end
+
profile.externalBars.hideOriginalIcons = false
ExternalBars:_RefreshOriginalIconsState()
+ ExternalBars:_RefreshOriginalIconsState()
- assert.same({ 0.35 }, setAlphaCalls)
- assert.are.equal(0.35, viewer:GetAlpha())
+ assert.same({ 1 }, setAlphaCalls)
+ assert.same({ true }, enableMouseCalls)
+ assert.are.equal(1, viewer:GetAlpha())
assert.is_true(viewer:IsMouseEnabled())
- assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ assert.same({}, requestLayoutReasons)
end)
- it("keeps viewer mouse disabled when the runtime fade alpha is zero", function()
+ it("does not restamp viewer alpha when original icons are already shown", function()
+ local setAlphaCalls = {}
+
profile.externalBars.hideOriginalIcons = true
ExternalBars:_RefreshOriginalIconsState()
- requestLayoutReasons = {}
- runtimeAlpha = 0
+
+ local originalSetAlpha = viewer.SetAlpha
+ function viewer:SetAlpha(alpha)
+ setAlphaCalls[#setAlphaCalls + 1] = alpha
+ originalSetAlpha(self, alpha)
+ end
profile.externalBars.hideOriginalIcons = false
ExternalBars:_RefreshOriginalIconsState()
- assert.are.equal(0, viewer:GetAlpha())
- assert.is_false(viewer:IsMouseEnabled())
- assert.same({ "ExternalBars:OriginalIconsShown" }, requestLayoutReasons)
+ setAlphaCalls = {}
+ ExternalBars:_RefreshOriginalIconsState()
+
+ assert.same({}, setAlphaCalls)
end)
it("computes container height from bar count, height, and vertical spacing", function()
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index caef3e8b..85d7a1f6 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -403,6 +403,11 @@ describe("BuffBarsOptions", function()
return row.items()
end
+ local function getSpellColorActions(spellColorsSpec)
+ local row = assert(getSpellColorsRow(spellColorsSpec, "spellColorsPageActions"))
+ return assert(row.actions)
+ end
+
local function getItemLabels(items)
local labels = {}
for _, item in ipairs(items or {}) do
@@ -413,7 +418,7 @@ describe("BuffBarsOptions", function()
it("does not add the old configure spell colors shortcut to aura bars", function()
local buttonRows = {}
- for _, row in ipairs(BuffBarsOptions.pages[1].rows) do
+ for _, row in ipairs(BuffBarsOptions.pages[1].rows) doy
if row.type == "button" then
buttonRows[#buttonRows + 1] = row
end
@@ -433,19 +438,20 @@ describe("BuffBarsOptions", function()
end
assert.same({
- "buffBarsSpellColorsPageActions",
+ "spellColorsPageActions",
"spellColorsDescription",
+ "buffBarsSpellColorsHeader",
"buffBarsSpellColorsWarning",
"buffBarsSpellColorCollection",
"buffBarsSecretNameDescription",
- "externalBarsSpellColorsPageActions",
+ "externalBarsSpellColorsHeader",
"externalBarsSpellColorsWarning",
"externalBarsSpellColorCollection",
"externalBarsSecretNameDescription",
}, rowIDs)
end)
- it("keeps a single action set per spell color section", function()
+ it("keeps a single shared action set on the spell colors page", function()
local spellColorsSpec = registerSpellColorsSpec()
local actionRowCount = 0
@@ -455,7 +461,9 @@ describe("BuffBarsOptions", function()
end
end
- assert.are.equal(2, actionRowCount)
+ assert.are.equal(1, actionRowCount)
+ assert.is_not_nil(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsHeader"))
+ assert.is_not_nil(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsHeader"))
assert.is_nil(assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorCollection")).onDefault)
assert.is_nil(assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorCollection")).onDefault)
end)
@@ -562,7 +570,7 @@ describe("BuffBarsOptions", function()
assert.are.same(pickedEntryColor, ExternalSpellColors:GetColorByKey(externalKey))
end)
- it("enables reconcile and remove-stale actions per section state", function()
+ it("enables the shared reconcile and remove-stale actions when any editable section needs them", function()
BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Buff Incomplete", 258920, nil, nil), {
r = 0.2, g = 0.3, b = 0.4, a = 1,
})
@@ -576,23 +584,21 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec = registerSpellColorsSpec()
- local buffActions = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsPageActions")).actions
- local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+ local actions = getSpellColorActions(spellColorsSpec)
- assert.is_true(buffActions[1].enabled())
- assert.is_true(buffActions[2].enabled())
- assert.is_false(externalActions[1].enabled())
- assert.is_false(externalActions[2].enabled())
+ assert.is_true(actions[1].enabled())
+ assert.is_true(actions[2].enabled())
- buffActions[1].onClick()
+ actions[1].onClick()
assert.are.equal(ns.L["SPELL_COLORS_SECRET_NAMES_DESC"], confirmText)
end)
- it("reset action clears only the targeted section", function()
+ it("shared reset clears all editable spell color sections", function()
local buffKey = SpellColors.MakeKey("Buff Keep", 111, 222, 333)
local externalKey = SpellColors.MakeKey("External Reset", 444, 555, 666)
local buffDefaultColor = { r = 0.2, g = 0.3, b = 0.4, a = 1 }
+ local buffResetDefaultColor = ns.Constants.BUFFBARS_DEFAULT_COLOR
local externalResetDefaultColor = ns.Constants.BUFFBARS_DEFAULT_COLOR
local externalCustomDefaultColor = { r = 0.9, g = 0.8, b = 0.7, a = 1 }
@@ -602,18 +608,18 @@ describe("BuffBarsOptions", function()
ExternalSpellColors:SetColorByKey(externalKey, { r = 0.6, g = 0.5, b = 0.4, a = 1 })
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+ local actions = getSpellColorActions(spellColorsSpec)
- externalActions[3].onClick()
+ actions[3].onClick()
- assert.are.same(buffDefaultColor, BuffSpellColors:GetDefaultColor())
+ assert.are.same(buffResetDefaultColor, BuffSpellColors:GetDefaultColor())
assert.are.same(externalResetDefaultColor, ExternalSpellColors:GetDefaultColor())
- assert.are.same({ r = 0.3, g = 0.4, b = 0.5, a = 1 }, BuffSpellColors:GetColorByKey(buffKey))
+ assert.is_nil(BuffSpellColors:GetColorByKey(buffKey))
assert.is_nil(ExternalSpellColors:GetColorByKey(externalKey))
assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
- it("remove stale action clears only the targeted section", function()
+ it("shared remove stale action clears stale entries across editable sections", function()
local buffKey = SpellColors.MakeKey("Buff Stale", 111, nil, nil)
local externalKey = SpellColors.MakeKey("External Stale", 444, nil, nil)
local acceptFn
@@ -625,16 +631,17 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local externalActions = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions")).actions
+ local actions = getSpellColorActions(spellColorsSpec)
- externalActions[2].onClick()
+ actions[2].onClick()
assert.is_function(acceptFn)
acceptFn()
- assert.are.same({ r = 0.2, g = 0.3, b = 0.4, a = 1 }, BuffSpellColors:GetColorByKey(buffKey))
+ assert.is_nil(BuffSpellColors:GetColorByKey(buffKey))
assert.is_nil(ExternalSpellColors:GetColorByKey(externalKey))
assert.are.same({
+ ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("Buff Stale"),
ns.L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format("External Stale"),
}, printedMessages)
assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
@@ -650,11 +657,13 @@ describe("BuffBarsOptions", function()
})
local spellColorsSpec = registerSpellColorsSpec()
- local buffHeader = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsPageActions"))
- local externalHeader = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsPageActions"))
+ local actions = getSpellColorActions(spellColorsSpec)
+ local buffHeader = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsHeader"))
+ local externalHeader = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsHeader"))
local buffItems = getSpellColorCollectionItems(spellColorsSpec, "buffBars")
local externalItems = getSpellColorCollectionItems(spellColorsSpec, "externalBars")
+ assert.is_true(actions[3].enabled())
assert.is_false(buffHeader.disabled())
assert.is_true(externalHeader.disabled())
assert.is_true(buffItems[1].color.enabled())
@@ -675,10 +684,11 @@ describe("BuffBarsOptions", function()
local spellColorsSpec = registerSpellColorsSpec()
assert.are.equal("pageActions", spellColorsSpec.rows[1].type)
- assert.are.equal("list", spellColorsSpec.rows[4].type)
- assert.are.equal("swatch", spellColorsSpec.rows[4].variant)
+ local buffCollection = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorCollection"))
+ assert.are.equal("list", buffCollection.type)
+ assert.are.equal("swatch", buffCollection.variant)
- local item = spellColorsSpec.rows[4].items()[2]
+ local item = buffCollection.items()[2]
item.onEnter(CreateFrame("Frame"))
@@ -705,7 +715,7 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local defaultItem = spellColorsSpec.rows[4].items()[1]
+ local defaultItem = getSpellColorCollectionItems(spellColorsSpec, "buffBars")[1]
assert.are.equal(ns.L["DEFAULT_COLOR"], defaultItem.label)
@@ -722,7 +732,7 @@ describe("BuffBarsOptions", function()
})
local spellColorsSpec = registerSpellColorsSpec()
- local actions = spellColorsSpec.rows[1].actions
+ local actions = getSpellColorActions(spellColorsSpec)
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
@@ -734,7 +744,7 @@ describe("BuffBarsOptions", function()
})
local spellColorsSpec = registerSpellColorsSpec()
- local actions = spellColorsSpec.rows[1].actions
+ local actions = getSpellColorActions(spellColorsSpec)
assert.is_true(actions[1].enabled())
assert.is_true(actions[2].enabled())
@@ -749,7 +759,7 @@ describe("BuffBarsOptions", function()
})
local spellColorsSpec = registerSpellColorsSpec()
- local actions = spellColorsSpec.rows[1].actions
+ local actions = getSpellColorActions(spellColorsSpec)
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
@@ -762,9 +772,36 @@ describe("BuffBarsOptions", function()
ns.Addon.BuffBars.IsEditLocked = function()
return true, "combat"
end
+ ns.Addon.ExternalBars.IsEditLocked = function()
+ return true, "combat"
+ end
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local actions = getSpellColorActions(spellColorsSpec)
+
+ assert.is_false(actions[1].enabled())
+ assert.is_false(actions[2].enabled())
+ assert.is_false(actions[3].enabled())
+ end)
+
+ it("header actions re-evaluate live edit locks after page creation", function()
+ BuffSpellColors:SetColorByKey(SpellColors.MakeKey("Immolation Aura", 258920, nil, nil), {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
local spellColorsSpec = registerSpellColorsSpec()
- local actions = spellColorsSpec.rows[1].actions
+ local actions = getSpellColorActions(spellColorsSpec)
+
+ assert.is_true(actions[1].enabled())
+ assert.is_true(actions[2].enabled())
+ assert.is_true(actions[3].enabled())
+
+ ns.Addon.BuffBars.IsEditLocked = function()
+ return true, "combat"
+ end
+ ns.Addon.ExternalBars.IsEditLocked = function()
+ return true, "combat"
+ end
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
@@ -782,7 +819,7 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec = registerSpellColorsSpec()
- spellColorsSpec.rows[1].actions[1].onClick()
+ getSpellColorActions(spellColorsSpec)[1].onClick()
assert.are.equal(ns.L["SPELL_COLORS_SECRET_NAMES_DESC"], confirmText)
end)
@@ -810,7 +847,7 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local removeStaleAction = spellColorsSpec.rows[1].actions[2]
+ local removeStaleAction = getSpellColorActions(spellColorsSpec)[2]
assert.are.equal(ns.L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"], removeStaleAction.tooltip)
@@ -845,6 +882,9 @@ describe("BuffBarsOptions", function()
ns.Addon.BuffBars.IsEditLocked = function()
return true, "combat"
end
+ ns.Addon.ExternalBars.IsEditLocked = function()
+ return true, "combat"
+ end
ns.Addon.ConfirmReloadUI = function(_, text)
confirmText = text
end
@@ -856,7 +896,7 @@ describe("BuffBarsOptions", function()
end
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local actions = spellColorsSpec.rows[1].actions
+ local actions = getSpellColorActions(spellColorsSpec)
actions[1].onClick()
actions[2].onClick()
diff --git a/Tests/UI/ExternalBarsOptions_spec.lua b/Tests/UI/ExternalBarsOptions_spec.lua
index 9b37f11d..3d5c9545 100644
--- a/Tests/UI/ExternalBarsOptions_spec.lua
+++ b/Tests/UI/ExternalBarsOptions_spec.lua
@@ -70,10 +70,12 @@ describe("ExternalBarsOptions", function()
local sharedPage = ns.SpellColorsPage.CreatePage(ns.L["SPELL_COLORS_SUBCAT"])
assert.are.equal("spellColors", sharedPage.key)
- assert.are.equal("externalBarsSpellColorsPageActions", sharedPage.rows[1].id)
+ assert.are.equal("spellColorsPageActions", sharedPage.rows[1].id)
assert.are.equal("pageActions", sharedPage.rows[1].type)
- assert.are.equal("externalBarsSpellColorsWarning", sharedPage.rows[3].id)
- assert.are.equal("externalBarsSpellColorCollection", sharedPage.rows[4].id)
- assert.are.equal("externalBarsSecretNameDescription", sharedPage.rows[5].id)
+ assert.are.equal("spellColorsDescription", sharedPage.rows[2].id)
+ assert.are.equal("externalBarsSpellColorsHeader", sharedPage.rows[3].id)
+ assert.are.equal("externalBarsSpellColorsWarning", sharedPage.rows[4].id)
+ assert.are.equal("externalBarsSpellColorCollection", sharedPage.rows[5].id)
+ assert.are.equal("externalBarsSecretNameDescription", sharedPage.rows[6].id)
end)
end)
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
index a32e361d..88a979d8 100644
--- a/UI/SpellColorsPage.lua
+++ b/UI/SpellColorsPage.lua
@@ -287,14 +287,10 @@ function SpellColorsPage.SetRegisteredPage(page)
end
---@param section table
----@param refreshPage fun()
-local function resetSpellColors(section, refreshPage)
+local function resetSpellColorSection(section)
local spellColors = getSpellColors(section.scope)
spellColors:ClearCurrentSpecColors()
spellColors:SetDefaultColor(getScopeDefaultColor(section.scope))
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
end
local function reconcileSpellColors()
@@ -302,38 +298,24 @@ local function reconcileSpellColors()
end
---@param section table
----@param refreshPage fun()
-local function removeStaleSpellColors(section, refreshPage)
+---@return ECM_SpellColorKey[]
+local function removeStaleSpellColorSection(section)
local staleRows = collectIncompleteSpellColorRows(nil, section.scope)
if #staleRows == 0 then
- return
+ return {}
end
- local spellColors = getSpellColors(section.scope)
-
- ns.Addon:ShowConfirmDialog(
- REMOVE_STALE_SPELL_COLORS_POPUP,
- L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
- L["REMOVE"],
- L["SPELL_COLORS_DONT_REMOVE"],
- function()
- local staleKeys = {}
- for _, row in ipairs(staleRows) do
- staleKeys[#staleKeys + 1] = row.key
- end
+ local staleKeys = {}
+ for _, row in ipairs(staleRows) do
+ staleKeys[#staleKeys + 1] = row.key
+ end
- local removedKeys = spellColors:RemoveEntriesByKeys(staleKeys)
- for _, key in ipairs(removedKeys) do
- ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key, section.scope)))
- end
+ local removedKeys = getSpellColors(section.scope):RemoveEntriesByKeys(staleKeys)
+ for _, key in ipairs(removedKeys) do
+ ns.Print(L["SPELL_COLORS_REMOVED_STALE_ENTRY"]:format(getSpellColorRowName(key, section.scope)))
+ end
- if #removedKeys > 0 then
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
- end
- end
- )
+ return removedKeys
end
---@param section table
@@ -413,29 +395,99 @@ local function buildSpellColorItems(section, refreshPage)
return items
end
----@param section table
+---@param predicate fun(section: table): boolean
+---@return boolean
+local function doesAnySpellColorSectionMatch(predicate)
+ for _, section in ipairs(spellColorSections) do
+ if predicate(section) then
+ return true
+ end
+ end
+
+ return false
+end
+
+---@return boolean
+local function canResetAnySpellColorSection()
+ return doesAnySpellColorSectionMatch(function(section)
+ return not isSpellColorSectionInteractionDisabled(section)
+ end)
+end
+
+---@return boolean
+local function canMaintainAnySpellColorSection()
+ return doesAnySpellColorSectionMatch(function(section)
+ return not isSpellColorSectionInteractionDisabled(section)
+ and getSectionSpellColorPageState(section).canReconcile
+ end)
+end
+
+---@param refreshPage fun()
+local function resetAllSpellColors(refreshPage)
+ local didReset = false
+
+ for _, section in ipairs(spellColorSections) do
+ if not isSpellColorSectionInteractionDisabled(section) then
+ resetSpellColorSection(section)
+ didReset = true
+ end
+ end
+
+ if didReset then
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+ end
+end
+
+---@param refreshPage fun()
+local function removeAllStaleSpellColors(refreshPage)
+ if not canMaintainAnySpellColorSection() then
+ return
+ end
+
+ ns.Addon:ShowConfirmDialog(
+ REMOVE_STALE_SPELL_COLORS_POPUP,
+ L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
+ L["REMOVE"],
+ L["SPELL_COLORS_DONT_REMOVE"],
+ function()
+ local removedAny = false
+
+ for _, section in ipairs(spellColorSections) do
+ if not isSpellColorSectionInteractionDisabled(section)
+ and getSectionSpellColorPageState(section).canReconcile then
+ local removedKeys = removeStaleSpellColorSection(section)
+ if #removedKeys > 0 then
+ removedAny = true
+ end
+ end
+ end
+
+ if removedAny then
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
+ end
+ end
+ )
+end
+
---@param refreshPage fun()
---@return table
-local function createSpellColorSectionHeaderRow(section, refreshPage)
+local function createSpellColorPageActionsRow(refreshPage)
return {
- id = section.key .. "SpellColorsPageActions",
+ id = "spellColorsPageActions",
type = "pageActions",
- name = section.label,
- attachToCategoryHeader = false,
- hideTitle = false,
- height = 28,
- disabled = section.isDisabledDelegate,
actions = {
{
text = L["SPELL_COLORS_RECONCILE_BUTTON"],
width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
enabled = function()
- return not isSpellColorSectionInteractionDisabled(section)
- and getSectionSpellColorPageState(section).canReconcile
+ return canMaintainAnySpellColorSection()
end,
onClick = function()
- if isSpellColorSectionInteractionDisabled(section)
- or not getSectionSpellColorPageState(section).canReconcile then
+ if not canMaintainAnySpellColorSection() then
return
end
@@ -447,36 +499,45 @@ local function createSpellColorSectionHeaderRow(section, refreshPage)
width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
tooltip = L["SPELL_COLORS_REMOVE_STALE_TOOLTIP"],
enabled = function()
- return not isSpellColorSectionInteractionDisabled(section)
- and getSectionSpellColorPageState(section).canReconcile
+ return canMaintainAnySpellColorSection()
end,
onClick = function()
- if isSpellColorSectionInteractionDisabled(section)
- or not getSectionSpellColorPageState(section).canReconcile then
+ if not canMaintainAnySpellColorSection() then
return
end
- removeStaleSpellColors(section, refreshPage)
+ removeAllStaleSpellColors(refreshPage)
end,
},
{
text = L["RESET"],
width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
enabled = function()
- return not isSpellColorSectionInteractionDisabled(section)
+ return canResetAnySpellColorSection()
end,
onClick = function()
- if isSpellColorSectionInteractionDisabled(section) then
+ if not canResetAnySpellColorSection() then
return
end
- resetSpellColors(section, refreshPage)
+ resetAllSpellColors(refreshPage)
end,
},
},
}
end
+---@param section table
+---@return table
+local function createSpellColorSectionHeaderRow(section)
+ return {
+ id = section.key .. "SpellColorsHeader",
+ type = "header",
+ name = section.label,
+ disabled = section.isDisabledDelegate,
+ }
+end
+
---@param section table
---@return table
local function createSpellColorWarningRow(section)
@@ -538,20 +599,21 @@ local function buildPageRows()
end
end
- for index, section in ipairs(spellColorSections) do
- rows[#rows + 1] = createSpellColorSectionHeaderRow(section, refreshPage)
+ if #spellColorSections > 0 then
+ rows[#rows + 1] = createSpellColorPageActionsRow(refreshPage)
+ rows[#rows + 1] = {
+ id = "spellColorsDescription",
+ type = "info",
+ name = "",
+ value = L["SPELL_COLORS_DESC"],
+ wide = true,
+ multiline = true,
+ height = 36,
+ }
+ end
- if index == 1 then
- rows[#rows + 1] = {
- id = "spellColorsDescription",
- type = "info",
- name = "",
- value = L["SPELL_COLORS_DESC"],
- wide = true,
- multiline = true,
- height = 36,
- }
- end
+ for _, section in ipairs(spellColorSections) do
+ rows[#rows + 1] = createSpellColorSectionHeaderRow(section)
rows[#rows + 1] = createSpellColorWarningRow(section)
rows[#rows + 1] = createSpellColorListRow(section, refreshPage)
From 0f42328ef38a2de85a6c8ff9f4ed6e13783b669a Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 20 Apr 2026 08:43:52 +1000
Subject: [PATCH 27/53] PR comments.
---
AGENTS.md | 1 +
BarStyle.lua | 49 ++++++++++-----
Migration.lua | 2 +-
README.md | 2 +-
Tests/BarMixin_spec.lua | 123 +++++++++++++++++++++++++++++++++++++
Tests/Migration_spec.lua | 128 ++++++++++++++++++++++-----------------
6 files changed, 232 insertions(+), 73 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 423dac2f..03720ee8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,6 +36,7 @@ luacheck . -q
- Target WoW Lua 5.1; do not use post-5.1 features such as `goto`, labels, or `//`.
- Do not add compatibility shims for built-ins already present in WoW. If a shim exists only for `busted`, document that.
- Do not nil-check or wrap built-ins such as `issecretvalue`, `issecrettable`, or `canaccesstable`.
+- Prefer assertions for required parameters over guards and fallbacks. This prevents unexpected states from propogating deeper in to the system.
## Config, Events, and State
diff --git a/BarStyle.lua b/BarStyle.lua
index ea47287f..4c992582 100644
--- a/BarStyle.lua
+++ b/BarStyle.lua
@@ -3,7 +3,6 @@
-- Licensed under the GNU General Public License v3.0
local _, ns = ...
-local C = ns.Constants
local FrameUtil = ns.FrameUtil
--------------------------------------------------------------------------------
@@ -62,7 +61,11 @@ end
---@param config table|nil
---@param globalConfig table|nil
local function styleBarHeight(frame, bar, iconFrame, config, globalConfig)
- local height = (config and config.height) or (globalConfig and globalConfig.barHeight) or 15
+ assert(frame ~= nil, "BarStyle.styleBarHeight requires a frame")
+ assert(bar ~= nil, "BarStyle.styleBarHeight requires a bar")
+
+ local height = (config and config.height) or (globalConfig and globalConfig.barHeight)
+ assert(type(height) == "number", "BarStyle.styleBarHeight requires config.height or globalConfig.barHeight")
if height <= 0 then
return
end
@@ -79,6 +82,8 @@ end
---@param config table|nil
---@param globalConfig table|nil
local function styleBarBackground(frame, barBG, config, globalConfig)
+ assert(frame ~= nil, "BarStyle.styleBarBackground requires a frame")
+
if not barBG then
return
end
@@ -97,7 +102,7 @@ local function styleBarBackground(frame, barBG, config, globalConfig)
local bgColor = (config and config.bgColor)
or (globalConfig and globalConfig.barBgColor)
- or ns.Constants.COLOR_BLACK
+ assert(bgColor ~= nil, "BarStyle.styleBarBackground requires config.bgColor or globalConfig.barBgColor")
barBG:SetTexture(ns.Constants.FALLBACK_TEXTURE)
barBG:SetVertexColor(bgColor.r, bgColor.g, bgColor.b, bgColor.a)
barBG:ClearAllPoints()
@@ -111,16 +116,21 @@ end
---@param frame ECM_BuffBarMixin|Frame
---@param bar StatusBar
---@param globalConfig table|nil
----@param spellColors ECM_SpellColorStore|nil
+---@param spellColors ECM_SpellColorStore
---@param retryCount number|nil
---@return boolean|nil
local function styleBarColor(module, frame, bar, globalConfig, spellColors, retryCount)
- local resolvedSpellColors = spellColors or ns.SpellColors.Get(C.SCOPE_BUFFBARS)
+ assert(module ~= nil, "BarStyle.styleBarColor requires a module")
+ assert(type(module.Name) == "string" and module.Name ~= "", "BarStyle.styleBarColor requires module.Name")
+ assert(frame ~= nil, "BarStyle.styleBarColor requires a frame")
+ assert(bar ~= nil, "BarStyle.styleBarColor requires a bar")
+ assert(spellColors ~= nil, "BarStyle.styleBarColor requires an explicit spellColors store")
+
local currentRetryCount = retryCount or 0
local textureName = globalConfig and globalConfig.texture
FrameUtil.LazySetStatusBarTexture(bar, FrameUtil.GetTexture(textureName))
- local barColor = resolvedSpellColors:GetColorForBar(frame)
+ local barColor = spellColors:GetColorForBar(frame)
local spellName = bar.Name and bar.Name.GetText and bar.Name:GetText()
local spellID = frame.cooldownInfo and frame.cooldownInfo.spellID
local cooldownID = frame.cooldownID
@@ -143,14 +153,14 @@ local function styleBarColor(module, frame, bar, globalConfig, spellColors, retr
end
frame._ecmColorRetryTimer = C_Timer.NewTimer(1, function()
frame._ecmColorRetryTimer = nil
- styleBarColor(module, frame, bar, globalConfig, resolvedSpellColors, currentRetryCount + 1)
+ styleBarColor(module, frame, bar, globalConfig, spellColors, currentRetryCount + 1)
end)
-- Don't apply any colour while retries are pending — preserve
-- the bar's existing colour rather than clobbering it with the
-- default while we wait for secrets to clear.
return nil
elseif ns.IsDebugEnabled() and not module._warned then
- ns.Log(ns.Constants.BUFFBARS, "All identifying keys are secret outside of combat.")
+ ns.Log(module.Name, "All identifying keys are secret outside of combat.")
module._warned = true
end
end
@@ -161,7 +171,7 @@ local function styleBarColor(module, frame, bar, globalConfig, spellColors, retr
end
if barColor == nil and not allSecret then
- barColor = resolvedSpellColors:GetDefaultColor()
+ barColor = spellColors:GetDefaultColor()
end
if barColor then
FrameUtil.LazySetStatusBarColor(bar, barColor.r, barColor.g, barColor.b, 1.0)
@@ -174,6 +184,8 @@ end
---@param iconFrame Frame|nil
---@param config table|nil
local function styleBarIcon(frame, iconFrame, config)
+ assert(frame ~= nil, "BarStyle.styleBarIcon requires a frame")
+
local showIcon = config and config.showIcon ~= false
if iconFrame then
@@ -203,6 +215,10 @@ end
---@param iconFrame Frame|nil
---@param config table|nil
local function styleBarAnchors(frame, bar, iconFrame, config)
+ assert(frame ~= nil, "BarStyle.styleBarAnchors requires a frame")
+ assert(bar ~= nil, "BarStyle.styleBarAnchors requires a bar")
+ assert(bar.Name ~= nil, "BarStyle.styleBarAnchors requires bar.Name")
+
local showSpellName = config and config.showSpellName ~= false
local showDuration = config and config.showDuration ~= false
if bar.Name then
@@ -238,15 +254,18 @@ end
---@param frame ECM_BuffBarMixin|Frame
---@param config table|nil
---@param globalConfig table|nil
----@param spellColors ECM_SpellColorStore|nil
+---@param spellColors ECM_SpellColorStore
local function styleChildBar(module, frame, config, globalConfig, spellColors)
- if not (frame and frame.__ecmHooked) then
- ns.DebugAssert(false, "Attempted to style a child frame that wasn't hooked.")
- return
- end
+ assert(module ~= nil, "BarStyle.styleChildBar requires a module")
+ assert(frame ~= nil, "BarStyle.styleChildBar requires a frame")
+ assert(frame.__ecmHooked, "Attempted to style a child frame that wasn't hooked.")
+ assert(spellColors ~= nil, "BarStyle.styleChildBar requires an explicit spellColors store")
- local bar = frame.Bar
+ local bar = assert(frame.Bar, "BarStyle.styleChildBar requires frame.Bar")
local iconFrame = frame.Icon
+ assert(bar.Pip ~= nil, "BarStyle.styleChildBar requires bar.Pip")
+ assert(bar.Name ~= nil, "BarStyle.styleChildBar requires bar.Name")
+ assert(bar.Duration ~= nil, "BarStyle.styleChildBar requires bar.Duration")
styleBarHeight(frame, bar, iconFrame, config, globalConfig)
diff --git a/Migration.lua b/Migration.lua
index 48ea1a8a..615dc428 100644
--- a/Migration.lua
+++ b/Migration.lua
@@ -1161,7 +1161,7 @@ end
_migrations[12] = function(profile)
local old = profile.itemIcons
if type(old) ~= "table" then
- log("V12 no itemIcons section found; seeding default extraIcons")
+ log("V12 no itemIcons section found; ensuring default extraIcons")
profile.extraIcons = profile.extraIcons or {
enabled = true,
viewers = {
diff --git a/README.md b/README.md
index 1bba3287..16f19d2d 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Enhanced Cooldown Manager by Argium
-Enhanced Cooldown Manager creates a clean combat HUD around Blizzard's built-in cooldown manager that **looks and works great out of the box** and is **straightforward to customise.** It adds a mana/power bar and resource bar, extra icons for potions, and supports per-spell color and customisation for aura bars. Modular design allow each part to be attached to the CDM or detached and freely placed.
+Enhanced Cooldown Manager creates a clean combat HUD around Blizzard's built-in cooldown manager that **looks and works great out of the box** and is **straightforward to customise.** It adds a mana/power bar and resource bar, extra icons for potions, and supports per-spell color and customisation for aura bars. Its modular design allows each part to be attached to the CDM or detached and freely placed.
Made with ❤️, with little features you won't want to live without.
diff --git a/Tests/BarMixin_spec.lua b/Tests/BarMixin_spec.lua
index ad60ec0b..451461af 100644
--- a/Tests/BarMixin_spec.lua
+++ b/Tests/BarMixin_spec.lua
@@ -17,6 +17,7 @@ describe("BarMixin", function()
originalGlobals = TestHelpers.CaptureGlobals({
"C_Timer",
"GetTime",
+ "InCombatLockdown",
"UIParent",
"CreateFrame",
"issecretvalue",
@@ -59,6 +60,9 @@ describe("BarMixin", function()
_G.GetTime = function()
return 0
end
+ _G.InCombatLockdown = function()
+ return false
+ end
_G.UIParent = makeFrame({ name = "UIParent" })
_G.issecretvalue = function()
return false
@@ -210,6 +214,125 @@ describe("BarMixin", function()
assert.is_function(BarStyle.StyleChildBar)
end)
+ describe("BarStyle", function()
+ local BarStyle
+
+ before_each(function()
+ ns.IsDebugEnabled = function()
+ return true
+ end
+ TestHelpers.LoadChunk("BarStyle.lua", "Unable to load BarStyle.lua")(nil, ns)
+ BarStyle = assert(ns.BarStyle, "BarStyle module did not initialize")
+ end)
+
+ it("logs secret-key warnings with the calling module name", function()
+ local loggedTag
+ local loggedMessage
+ local secretSpellName = { __secret = true }
+ local secretSpellID = { __secret = true }
+ local secretCooldownID = { __secret = true }
+ local secretTextureFileID = { __secret = true }
+ local module = { Name = ns.Constants.EXTERNALBARS }
+
+ ns.Log = function(tag, message)
+ loggedTag = tag
+ loggedMessage = message
+ end
+ _G.issecretvalue = function(value)
+ return type(value) == "table" and value.__secret == true
+ end
+
+ local iconTexture = {
+ IsObjectType = function(_, objectType)
+ return objectType == "Texture"
+ end,
+ GetTextureFileID = function()
+ return secretTextureFileID
+ end,
+ }
+ local bar = {
+ Name = {
+ GetText = function()
+ return secretSpellName
+ end,
+ },
+ _statusBarTexturePath = nil,
+ SetStatusBarTexture = function(self, texturePath)
+ self._statusBarTexturePath = texturePath
+ end,
+ GetStatusBarTexture = function(self)
+ local texturePath = self._statusBarTexturePath
+ if not texturePath then
+ return nil
+ end
+ return {
+ GetTexture = function()
+ return texturePath
+ end,
+ }
+ end,
+ GetStatusBarColor = function()
+ return 0, 0, 0, 1
+ end,
+ SetStatusBarColor = function()
+ error("expected no color update while all keys remain secret")
+ end,
+ }
+ local frame = {
+ Icon = {
+ GetRegions = function()
+ return iconTexture
+ end,
+ },
+ cooldownInfo = { spellID = secretSpellID },
+ cooldownID = secretCooldownID,
+ }
+ local spellColors = {
+ GetColorForBar = function()
+ return nil
+ end,
+ GetDefaultColor = function()
+ error("expected no default color fallback while all keys remain secret")
+ end,
+ }
+
+ local editLocked = BarStyle.StyleBarColor(module, frame, bar, {}, spellColors, 3)
+
+ assert.is_true(editLocked)
+ assert.is_true(module._warned)
+ assert.are.equal(ns.Constants.EXTERNALBARS, loggedTag)
+ assert.are.equal("All identifying keys are secret outside of combat.", loggedMessage)
+ end)
+
+ it("uses asserts instead of guessing missing style inputs", function()
+ assert.has_error(function()
+ BarStyle.StyleBarHeight({}, {}, nil, nil, nil)
+ end)
+
+ assert.has_error(function()
+ BarStyle.StyleBarBackground({}, {
+ SetParent = function() end,
+ SetTexture = function() end,
+ SetVertexColor = function() end,
+ ClearAllPoints = function() end,
+ SetAllPoints = function() end,
+ SetDrawLayer = function() end,
+ }, nil, nil)
+ end)
+
+ assert.has_error(function()
+ BarStyle.StyleBarColor({}, {}, {}, {}, {
+ GetColorForBar = function()
+ return nil
+ end,
+ GetDefaultColor = function()
+ return nil
+ end,
+ }, 0)
+ end)
+ end)
+ end)
+
describe("tick helpers", function()
local function makeTick()
return {
diff --git a/Tests/Migration_spec.lua b/Tests/Migration_spec.lua
index 6e8974a5..c4a61203 100644
--- a/Tests/Migration_spec.lua
+++ b/Tests/Migration_spec.lua
@@ -59,6 +59,22 @@ describe("Migration", function()
}
end
+ local SCHEMA_V10 = 10
+ local SCHEMA_V11 = 11
+ local SCHEMA_V12 = 12
+
+ local function runMigrationToVersion(profile, targetVersion)
+ local previousVersion = ns.Constants.CURRENT_SCHEMA_VERSION
+ ns.Constants.CURRENT_SCHEMA_VERSION = targetVersion
+
+ local ok, err = pcall(Migration.Run, profile)
+
+ ns.Constants.CURRENT_SCHEMA_VERSION = previousVersion
+ if not ok then
+ error(err, 0)
+ end
+ end
+
setup(function()
originalGlobals = TestHelpers.CaptureGlobals({
"C_EditMode",
@@ -135,9 +151,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.is_nil(persistedColor.keyType)
assert.is_nil(persistedColor.spellID)
assert.is_nil(persistedColor.cooldownID)
@@ -169,9 +185,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.are.equal("spellID", spellIDEntry.meta.keyType)
assert.are.equal(2468, spellIDEntry.meta.spellID)
assert.are.equal("cooldownID", cooldownEntry.meta.keyType)
@@ -203,9 +219,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.are.same(byNameEntry, profile.buffBars.colors.byName[12][2]["Do Not Replace"])
assert.are.same(byNameEntry, profile.buffBars.colors.bySpellID[12][2][1001])
assert.are.same(byNameEntry, profile.buffBars.colors.byCooldownID[12][2][1002])
@@ -218,8 +234,8 @@ describe("Migration", function()
local noBuffBars = {
schemaVersion = 8,
}
- Migration.Run(noBuffBars)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, noBuffBars.schemaVersion)
+ runMigrationToVersion(noBuffBars, SCHEMA_V10)
+ assert.are.equal(SCHEMA_V10, noBuffBars.schemaVersion)
local invalidColors = {
schemaVersion = 8,
@@ -227,8 +243,8 @@ describe("Migration", function()
colors = "invalid",
},
}
- Migration.Run(invalidColors)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidColors.schemaVersion)
+ runMigrationToVersion(invalidColors, SCHEMA_V10)
+ assert.are.equal(SCHEMA_V10, invalidColors.schemaVersion)
assert.are.equal("invalid", invalidColors.buffBars.colors)
local invalidByName = {
@@ -242,8 +258,8 @@ describe("Migration", function()
},
},
}
- Migration.Run(invalidByName)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidByName.schemaVersion)
+ runMigrationToVersion(invalidByName, SCHEMA_V10)
+ assert.are.equal(SCHEMA_V10, invalidByName.schemaVersion)
assert.is_table(invalidByName.buffBars.colors.byName)
end)
@@ -281,9 +297,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
local summary = assert(searchLogMessages("V10 spell color normalization summary:"))
local tierBreakdown = assert(searchLogMessages("V10 tier breakdown:"))
local created = assert(searchLogMessages("V10 created missing tier stores: byCooldownID, byTexture"))
@@ -371,9 +387,9 @@ describe("Migration", function()
it("logs V10 skip diagnostics when spell-color stores are unavailable", function()
local noBuffBars = { schemaVersion = 9 }
- Migration.Run(noBuffBars)
+ runMigrationToVersion(noBuffBars, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, noBuffBars.schemaVersion)
+ assert.are.equal(SCHEMA_V10, noBuffBars.schemaVersion)
assert.is_not_nil(searchLogMessages("V10 spell color normalization skipped: buffBars.colors missing"))
assert.is_nil(searchLogMessages("V10 spell color normalization summary:"))
assert.is_nil(searchLogMessages("V10 tier breakdown:"))
@@ -384,9 +400,9 @@ describe("Migration", function()
schemaVersion = 9,
buffBars = { colors = "invalid" },
}
- Migration.Run(invalidColors)
+ runMigrationToVersion(invalidColors, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, invalidColors.schemaVersion)
+ assert.are.equal(SCHEMA_V10, invalidColors.schemaVersion)
assert.is_not_nil(searchLogMessages("V10 spell color normalization skipped: buffBars.colors missing"))
assert.is_nil(searchLogMessages("V10 spell color normalization summary:"))
assert.is_nil(searchLogMessages("V10 tier breakdown:"))
@@ -413,9 +429,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
local summary = assert(searchLogMessages("V10 spell color normalization summary:"))
local tierBreakdown = assert(searchLogMessages("V10 tier breakdown:"))
local specAnomalies = select(2, searchLogMessages("V10 anomaly: class=12 spec="))
@@ -460,9 +476,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.is_table(profile.buffBars.colors.bySpellID)
assert.is_table(profile.buffBars.colors.byCooldownID)
assert.is_table(profile.buffBars.colors.byTexture)
@@ -496,9 +512,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.are.equal("spellName", byNameEntry.meta.keyType)
assert.are.equal("KeepNameMetadata", byNameEntry.meta.spellName)
assert.are.equal("spellID", bySpellIDEntry.meta.keyType)
@@ -544,9 +560,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.is_table(profile.buffBars.colors.bySpellID[12][2])
assert.is_table(profile.buffBars.colors.byCooldownID[12][2])
assert.is_table(profile.buffBars.colors.byTexture[12][2])
@@ -575,9 +591,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
assert.are.equal("spellName", byNameEntry.meta.keyType)
assert.are.equal(9001, byNameEntry.meta.spellID)
assert.are.equal(9002, byNameEntry.meta.cooldownID)
@@ -602,9 +618,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(11, profile.schemaVersion)
assert.is_nil(spellIDEntry.value.keyType)
assert.is_nil(spellIDEntry.value.spellID)
assert.is_nil(profile.buffBars.colors.byCooldownID)
@@ -627,9 +643,9 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V10)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V10, profile.schemaVersion)
local winner = profile.buffBars.colors.bySpellID[12][2][203720]
assert.are.same(bySpellEntry, winner)
assert.are.same(winner, profile.buffBars.colors.byName[12][2]["Demon Spikes"])
@@ -656,9 +672,9 @@ describe("Migration", function()
buffBars = { anchorPoint = "TOP", relativePoint = "BOTTOM", offsetX = 10, offsetY = -350 },
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V11, profile.schemaVersion)
-- powerBar: both offsets migrated, cleared
assert.is_nil(profile.powerBar.offsetX)
@@ -704,9 +720,9 @@ describe("Migration", function()
buffBars = { anchorMode = "chain" },
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V11, profile.schemaVersion)
assert.is_nil(profile.powerBar.editModePositions)
assert.is_nil(profile.resourceBar.editModePositions)
assert.is_nil(profile.runeBar.editModePositions)
@@ -722,7 +738,7 @@ describe("Migration", function()
runeBar = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
assert.is_nil(profile.buffBars.anchorPoint)
assert.is_nil(profile.buffBars.relativePoint)
@@ -737,7 +753,7 @@ describe("Migration", function()
buffBars = { anchorMode = ns.Constants.ANCHORMODE_FREE },
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
local expected = {
powerBar = { point = "CENTER", x = 0, y = -275 },
@@ -760,7 +776,7 @@ describe("Migration", function()
buffBars = { anchorMode = ns.Constants.ANCHORMODE_FREE },
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
assertAbsolutePositionPreserved(ns, nil, nil, 0, -275, profile.powerBar.editModePositions.Modern)
assertAbsolutePositionPreserved(ns, nil, nil, 0, -300, profile.resourceBar.editModePositions.Modern)
@@ -799,7 +815,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
assertAbsolutePositionPreserved(ns, nil, nil, 5, -275, profile.powerBar.editModePositions.Modern)
assertAbsolutePositionPreserved(ns, "TOP", "BOTTOM", 0, -300, profile.resourceBar.editModePositions.Modern)
@@ -836,7 +852,7 @@ describe("Migration", function()
runeBar = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
local migrated = profile.buffBars.editModePositions.Modern
assert.are.equal("TOPLEFT", migrated.point)
@@ -860,7 +876,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
local powerLog = assert(searchLogMessages("powerBar: migrated to editModePositions["))
assert.is_not_nil(string.find(powerLog, "source=legacy-free-default", 1, true))
@@ -883,7 +899,7 @@ describe("Migration", function()
buffBars = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
-- Existing Modern entry is preserved; Classic gets the migrated value
assert.are.equal(50, profile.powerBar.editModePositions.Modern.x)
@@ -899,9 +915,9 @@ describe("Migration", function()
buffBars = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V11, profile.schemaVersion)
local expected = { point = "CENTER", x = 0, y = -275 }
assert.same(expected, profile.powerBar.editModePositions.Modern)
assert.same(expected, profile.powerBar.editModePositions.Classic)
@@ -925,9 +941,9 @@ describe("Migration", function()
buffBars = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V11, profile.schemaVersion)
local expected = { point = "CENTER", x = 0, y = -275 }
assert.same(expected, profile.powerBar.editModePositions.Modern)
assert.same(expected, profile.powerBar.editModePositions.Classic)
@@ -945,9 +961,9 @@ describe("Migration", function()
buffBars = {},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V11)
- assert.are.equal(ns.Constants.CURRENT_SCHEMA_VERSION, profile.schemaVersion)
+ assert.are.equal(SCHEMA_V11, profile.schemaVersion)
assert.is_nil(profile.powerBar.editModePositions)
assert.are.equal(-275, profile.powerBar.offsetY)
assert.is_not_nil(searchLogMessages("V11 no layouts available; skipping position migration"))
@@ -968,7 +984,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.are.equal(12, profile.schemaVersion)
assert.is_nil(profile.itemIcons)
@@ -996,7 +1012,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.are.equal(12, profile.schemaVersion)
assert.same({
@@ -1019,7 +1035,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.is_false(profile.extraIcons.enabled)
end)
@@ -1030,7 +1046,7 @@ describe("Migration", function()
itemIcons = { enabled = true },
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.are.equal(12, profile.schemaVersion)
assert.same({
@@ -1047,7 +1063,7 @@ describe("Migration", function()
schemaVersion = 11,
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.are.equal(12, profile.schemaVersion)
assert.is_nil(profile.itemIcons)
@@ -1065,7 +1081,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
assert.are.equal(12, profile.schemaVersion)
assert.is_false(profile.extraIcons.enabled)
@@ -1085,7 +1101,7 @@ describe("Migration", function()
},
}
- Migration.Run(profile)
+ runMigrationToVersion(profile, SCHEMA_V12)
local logMsg = searchLogMessages("V12 migrated itemIcons")
assert.is_not_nil(logMsg)
From df7ebb5fe40028bd65c0570758d6b25877a66bae Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:45:35 +1000
Subject: [PATCH 28/53] compress
---
.../repo/extraicons-test-anchor-resolution.md | 2 +
Modules/ExtraIcons.lua | 672 +++++++-----------
Tests/Modules/ExtraIcons_spec.lua | 288 +++++++-
3 files changed, 514 insertions(+), 448 deletions(-)
create mode 100644 .serena/memories/repo/extraicons-test-anchor-resolution.md
diff --git a/.serena/memories/repo/extraicons-test-anchor-resolution.md b/.serena/memories/repo/extraicons-test-anchor-resolution.md
new file mode 100644
index 00000000..779bd938
--- /dev/null
+++ b/.serena/memories/repo/extraicons-test-anchor-resolution.md
@@ -0,0 +1,2 @@
+- In ExtraIcons specs, do not capture `UIParent`, `EssentialCooldownViewer`, or `UtilityCooldownViewer` in parameter tables at file load time; they are initialized in `before_each`.
+- Store symbolic targets like `"UIParent"`/`"main"` and resolve them inside the helper right before `SetPoint`/assertions, otherwise regressions silently use `nil` anchors and test the wrong geometry.
\ No newline at end of file
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 7111b418..917cacf5 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -7,195 +7,159 @@ local BarMixin = ns.BarMixin
local ExtraIcons = ns.Addon:NewModule("ExtraIcons")
ns.Addon.ExtraIcons = ExtraIcons
----@class ECM_ExtraIconsModule : ECM_FrameProto
-
----@class ECM_IconData
----@field itemId number|nil Item ID.
----@field spellId number|nil Spell ID (for spell-kind entries).
----@field texture string|number Icon texture.
----@field slotId number|nil Inventory slot ID (equipSlot only).
-
----@class ECM_ExtraIcon : Button
----@field slotId number|nil Inventory slot ID this icon represents (equipSlot only).
----@field itemId number|nil Item ID this icon represents (bag items only).
----@field spellId number|nil Spell ID this icon represents (spell entries only).
----@field Icon Texture The icon texture.
----@field Cooldown Cooldown The cooldown overlay frame.
-
local BUILTIN_STACKS = ns.Constants.BUILTIN_STACKS
+local DEFAULT_SIZE = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
+local BORDER_SCALE = ns.Constants.EXTRA_ICON_BORDER_SCALE
+
local SUPPRESS_IN_RATED_PVP = {
combatPotions = true,
healthPotions = true,
}
---- Viewer registry mapping viewer keys to their Blizzard frame globals.
-local VIEWER_REGISTRY = {
- utility = { blizzFrameKey = "UtilityCooldownViewer" },
- main = { blizzFrameKey = "EssentialCooldownViewer" },
+-- Ordered viewer keys mapped to their Blizzard frame globals.
+local VIEWERS = {
+ { key = "main", blizzKey = "EssentialCooldownViewer" },
+ { key = "utility", blizzKey = "UtilityCooldownViewer" },
}
+local BLIZZ_KEY = {}
+for _, v in ipairs(VIEWERS) do BLIZZ_KEY[v.key] = v.blizzKey end
--- Keep main first: utility may reuse the current pass's main viewer offset when
--- its cached Blizzard anchor is not relative to the main viewer.
-local VIEWER_ORDER = { "main", "utility" }
-
-local function cacheOriginalPoint(viewerState, blizzFrame)
- if viewerState.originalPoint or not blizzFrame then
- return
- end
+--------------------------------------------------------------------------------
+-- Shared horizontal centering
+--------------------------------------------------------------------------------
+local function cachePoint(vs, blizzFrame)
+ if vs.originalPoint or not blizzFrame then return end
local point, relativeTo, relativePoint, x, y = blizzFrame:GetPoint()
- viewerState.originalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
+ vs.originalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
end
-local function applyViewerPoint(viewerState, blizzFrame, offsetX)
- local point = viewerState and viewerState.originalPoint
- if not point or not blizzFrame then
- return
+local function applyPoint(vs, blizzFrame, offsetX)
+ local p = vs and vs.originalPoint
+ if not p or not blizzFrame then return end
+ blizzFrame:ClearAllPoints()
+ blizzFrame:SetPoint(p[1], p[2], p[3], p[4] + (offsetX or 0), p[5])
+end
+
+local function horizontalBounds(point, width)
+ if point == "LEFT" or point == "TOPLEFT" or point == "BOTTOMLEFT" then
+ return 0, width
+ elseif point == "RIGHT" or point == "TOPRIGHT" or point == "BOTTOMRIGHT" then
+ return -width, 0
+ end
+ local h = width / 2
+ return -h, h
+end
+
+--- Computes a per-viewer horizontal offset that re-centers both viewers as a
+--- single stacked group when they share the same original anchor.
+local function getSharedOffsets(viewers)
+ local offsets = { main = 0, utility = 0 }
+ local mainState, utilState = viewers.main, viewers.utility
+ if not mainState or not utilState then return offsets end
+
+ local mainFrame = _G[BLIZZ_KEY.main]
+ local utilFrame = _G[BLIZZ_KEY.utility]
+ cachePoint(mainState, mainFrame)
+ cachePoint(utilState, utilFrame)
+
+ local mp, up = mainState.originalPoint, utilState.originalPoint
+ if not mp or not up then return offsets end
+ if mainFrame and up[2] == mainFrame then return offsets end
+ if up[1] ~= mp[1] or up[2] ~= mp[2] or up[3] ~= mp[3] or up[4] ~= mp[4] then
+ return offsets
+ end
+
+ local sharedLeft, sharedRight
+ for _, v in ipairs(VIEWERS) do
+ local frame = _G[v.blizzKey]
+ local p = viewers[v.key].originalPoint
+ if frame and frame:IsShown() and p then
+ local l, r = horizontalBounds(p[1], frame:GetWidth() or 0)
+ sharedLeft = sharedLeft and math.min(sharedLeft, l) or l
+ sharedRight = sharedRight and math.max(sharedRight, r) or r
+ end
end
+ if not sharedLeft then return offsets end
+ local center = (sharedLeft + sharedRight) / 2
- blizzFrame:ClearAllPoints()
- blizzFrame:SetPoint(point[1], point[2], point[3], point[4] + (offsetX or 0), point[5])
+ for _, v in ipairs(VIEWERS) do
+ local frame = _G[v.blizzKey]
+ local p = viewers[v.key].originalPoint
+ if frame and frame:IsShown() and p then
+ local l, r = horizontalBounds(p[1], frame:GetWidth() or 0)
+ offsets[v.key] = center - ((l + r) / 2)
+ end
+ end
+ return offsets
end
--------------------------------------------------------------------------------
--- Resolver Functions
+-- Entry resolution
--------------------------------------------------------------------------------
-local function isRatedPvPMap()
- local pvp = C_PvP
- return pvp and type(pvp.IsRatedMap) == "function" and pvp.IsRatedMap() or false
-end
-
---- Checks if an equipment slot has an on-use effect.
----@param slotId number Inventory slot ID.
----@return ECM_IconData|nil iconData Icon data if on-use, nil otherwise.
-local function getEquipSlotData(slotId)
+local function resolveEquipSlot(slotId)
local itemId = GetInventoryItemID("player", slotId)
- if not itemId then
- return nil
- end
-
+ if not itemId then return nil end
local _, spellId = C_Item.GetItemSpell(itemId)
- if not spellId then
- return nil
- end
-
+ if not spellId then return nil end
local texture = GetInventoryItemTexture("player", slotId)
- if not texture then
- return nil
- end
-
- return {
- itemId = itemId,
- texture = texture,
- slotId = slotId,
- }
+ if not texture then return nil end
+ return { itemId = itemId, texture = texture, slotId = slotId }
end
---- Returns the first item from a priority list that exists in the player's bags.
----@param ids { itemID: number, quality: number|nil }[] Array of priority entries.
----@return ECM_IconData|nil iconData Icon data if found, nil otherwise.
-local function getBestConsumable(ids)
+local function resolveItem(ids)
for _, entry in ipairs(ids) do
local itemId = entry.itemID or entry.itemId
if itemId and C_Item.GetItemCount(itemId) > 0 then
local texture = C_Item.GetItemIconByID(itemId)
- if texture then
- return {
- itemId = itemId,
- texture = texture,
- }
- end
+ if texture then return { itemId = itemId, texture = texture } end
end
end
- return nil
-end
-
---- Returns the first known spell from an ID list.
----@param ids { spellId: number }[]|number[] Array of spell entries or raw IDs.
----@return ECM_IconData|nil iconData Icon data if found, nil otherwise.
-local function isKnownSpell(spellId)
- if not spellId then
- return false
- end
-
- return C_SpellBook.IsSpellKnown(spellId)
end
-local function getSpellData(ids)
+local function resolveSpell(ids)
for _, entry in ipairs(ids) do
local spellId = type(entry) == "table" and entry.spellId or entry
- if isKnownSpell(spellId) then
+ if spellId and C_SpellBook.IsSpellKnown(spellId) then
local texture = C_Spell.GetSpellTexture(spellId)
- if texture then
- return {
- spellId = spellId,
- texture = texture,
- }
- end
+ if texture then return { spellId = spellId, texture = texture } end
end
end
- return nil
end
---- Resolves a single config entry to displayable icon data.
----@param entry table A config entry with stackKey or kind+ids/slotId.
----@return ECM_IconData|nil iconData Resolved icon data, or nil if unavailable.
local function resolveEntry(entry)
local kind, slotId, ids
-
if entry.stackKey then
local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then
+ if not stack then return nil end
+ if SUPPRESS_IN_RATED_PVP[entry.stackKey] and C_PvP.IsRatedMap() then
return nil
end
- if SUPPRESS_IN_RATED_PVP[entry.stackKey] and isRatedPvPMap() then
- return nil
- end
- kind = stack.kind
- slotId = stack.slotId
- ids = stack.ids
+ kind, slotId, ids = stack.kind, stack.slotId, stack.ids
else
- kind = entry.kind
- slotId = entry.slotId
- ids = entry.ids
- end
-
- if kind == "equipSlot" then
- return getEquipSlotData(slotId)
- elseif kind == "item" then
- return ids and getBestConsumable(ids)
- elseif kind == "spell" then
- return ids and getSpellData(ids)
+ kind, slotId, ids = entry.kind, entry.slotId, entry.ids
end
- return nil
+ if kind == "equipSlot" then return resolveEquipSlot(slotId) end
+ if kind == "item" then return ids and resolveItem(ids) end
+ if kind == "spell" then return ids and resolveSpell(ids) end
end
---- Resolves all entries for a viewer into an ordered array of icon data.
----@param entries table[] Config entries for one viewer.
----@return ECM_IconData[] items Array of resolved icon data (skipping nil results).
-local _resolvedItems = {}
-
-local function resolveViewerEntries(entries)
- wipe(_resolvedItems)
+local _resolved = {}
+local function resolveEntries(entries)
+ wipe(_resolved)
for _, entry in ipairs(entries) do
local data = not entry.disabled and resolveEntry(entry) or nil
- if data then
- _resolvedItems[#_resolvedItems + 1] = data
- end
+ if data then _resolved[#_resolved + 1] = data end
end
- return _resolvedItems
+ return _resolved
end
--------------------------------------------------------------------------------
--- Icon Creation and Cooldown
+-- Icon creation and cooldown
--------------------------------------------------------------------------------
---- Creates a single extra icon frame styled like cooldown viewer icons.
----@param parent Frame Parent frame to attach to.
----@param size number Icon size in pixels.
----@return ECM_ExtraIcon icon The created icon frame.
-local function createExtraIcon(parent, size)
+local function createIcon(parent, size)
local icon = CreateFrame("Button", nil, parent)
icon:SetSize(size, size)
@@ -220,7 +184,7 @@ local function createExtraIcon(parent, size)
icon.Border = icon:CreateTexture(nil, "OVERLAY")
icon.Border:SetAtlas("UI-HUD-CoolDownManager-IconOverlay")
icon.Border:SetPoint("CENTER")
- icon.Border:SetSize(size * ns.Constants.EXTRA_ICON_BORDER_SCALE, size * ns.Constants.EXTRA_ICON_BORDER_SCALE)
+ icon.Border:SetSize(size * BORDER_SCALE, size * BORDER_SCALE)
icon.Shadow = icon:CreateTexture(nil, "OVERLAY")
icon.Shadow:SetAtlas("UI-CooldownManager-OORshadow")
@@ -230,8 +194,6 @@ local function createExtraIcon(parent, size)
return icon
end
---- Updates the cooldown display on an extra icon.
----@param icon ECM_ExtraIcon The icon to update.
local function updateIconCooldown(icon)
if icon.spellId then
local cdInfo = C_Spell.GetSpellCooldown(icon.spellId)
@@ -239,14 +201,13 @@ local function updateIconCooldown(icon)
icon.Cooldown:SetCooldown(0, 0)
return
end
- -- Charge spells: show per-charge timer so the icon appears ready while
- -- charges remain. maxCharges must be >1; single-charge spells report
- -- zero-span from GetSpellChargeDuration.
- local chargesInfo = C_Spell.GetSpellCharges(icon.spellId)
- local isCharge = chargesInfo and chargesInfo.maxCharges and chargesInfo.maxCharges > 1
+ -- Charge spells: per-charge timer so the icon shows ready while
+ -- charges remain. Single-charge spells report zero-span here.
+ local charges = C_Spell.GetSpellCharges(icon.spellId)
+ local isCharge = charges and charges.maxCharges and charges.maxCharges > 1
local durObj = isCharge
and C_Spell.GetSpellChargeDuration(icon.spellId)
- or C_Spell.GetSpellCooldownDuration(icon.spellId)
+ or C_Spell.GetSpellCooldownDuration(icon.spellId)
if durObj then
icon.Cooldown:SetCooldown(0, 0)
icon.Cooldown:SetCooldownFromDurationObject(durObj)
@@ -270,82 +231,48 @@ local function updateIconCooldown(icon)
end
end
---- Gets cooldown number font info from a Blizzard cooldown viewer icon.
---- Caches the result on the viewer to avoid per-layout child scans.
----@param viewer Frame
----@return string|nil fontPath, number|nil fontSize, string|nil fontFlags
-local function getSiblingCooldownNumberFont(viewer)
+--- Caches and returns the cooldown number font from a sibling Blizzard icon.
+local function getSiblingFont(viewer)
local cached = viewer.__ecmCDFont
- if cached then
- return cached[1], cached[2], cached[3]
- end
+ if cached then return cached[1], cached[2], cached[3] end
for _, child in ipairs({ viewer:GetChildren() }) do
local cooldown = child.Cooldown
if cooldown and cooldown.GetRegions then
local region = cooldown:GetRegions()
if region and region.IsObjectType and region:IsObjectType("FontString") and region.GetFont then
- local fontPath, fontSize, fontFlags = region:GetFont()
- if fontPath and fontSize then
- viewer.__ecmCDFont = { fontPath, fontSize, fontFlags }
- return fontPath, fontSize, fontFlags
+ local fp, fs, ff = region:GetFont()
+ if fp and fs then
+ viewer.__ecmCDFont = { fp, fs, ff }
+ return fp, fs, ff
end
end
end
end
end
---- Applies cooldown number font settings to one icon.
----@param icon ECM_ExtraIcon
----@param fontPath string
----@param fontSize number
----@param fontFlags string|nil
-local function applyCooldownNumberFont(icon, fontPath, fontSize, fontFlags)
- local region = icon.Cooldown:GetRegions()
- if region and region.IsObjectType and region:IsObjectType("FontString") and region.SetFont then
- region:SetFont(fontPath, fontSize, fontFlags)
- end
-end
-
---- Ensures the viewer's icon pool has at least `needed` icons.
----@param viewerState table Per-viewer state with .container and .iconPool.
----@param needed number Required pool size.
-local function ensurePoolSize(viewerState, needed)
- local pool = viewerState.iconPool
- local existing = #pool
- if needed <= existing then
- return
- end
- local size = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
- for i = existing + 1, needed do
- pool[i] = createExtraIcon(viewerState.container, size)
- end
-end
-
--------------------------------------------------------------------------------
--- Module Methods
+-- Module methods
--------------------------------------------------------------------------------
---- Creates the invisible parent frame and per-viewer containers.
----@return Frame parent The parent frame.
function ExtraIcons:CreateFrame()
local parent = CreateFrame("Frame", "ECMExtraIcons", UIParent)
parent:SetFrameStrata("MEDIUM")
parent:SetSize(1, 1)
self._viewers = {}
- for viewerKey in pairs(VIEWER_REGISTRY) do
- local container = CreateFrame("Frame", "ECMExtraIcons_" .. viewerKey, parent)
+ for _, v in ipairs(VIEWERS) do
+ local container = CreateFrame("Frame", "ECMExtraIcons_" .. v.key, parent)
container:SetFrameStrata("MEDIUM")
container:SetSize(1, 1)
- local anchorFrame = CreateFrame("Frame", "ECMExtraIcons_" .. viewerKey .. "Anchor", parent)
- anchorFrame:SetFrameStrata("MEDIUM")
- anchorFrame:SetSize(1, 1)
- anchorFrame:Hide()
+ local anchor = CreateFrame("Frame", "ECMExtraIcons_" .. v.key .. "Anchor", parent)
+ anchor:SetFrameStrata("MEDIUM")
+ anchor:SetSize(1, 1)
+ anchor:Hide()
- self._viewers[viewerKey] = {
- anchorFrame = anchorFrame,
+ self._viewers[v.key] = {
+ anchorFrame = anchor,
container = container,
iconPool = {},
originalPoint = nil,
@@ -356,240 +283,165 @@ function ExtraIcons:CreateFrame()
return parent
end
---- Override ShouldShow to check module enabled state and at least one viewer visible.
----@return boolean shouldShow Whether we should attempt layout.
function ExtraIcons:ShouldShow()
- if not BarMixin.FrameProto.ShouldShow(self) then
- return false
- end
- for _, reg in pairs(VIEWER_REGISTRY) do
- local blizzFrame = _G[reg.blizzFrameKey]
- if blizzFrame and blizzFrame:IsShown() then
- return true
- end
+ if not BarMixin.FrameProto.ShouldShow(self) then return false end
+ for _, v in ipairs(VIEWERS) do
+ local frame = _G[v.blizzKey]
+ if frame and frame:IsShown() then return true end
end
return false
end
---- Updates a viewer's logical anchor frame.
---- The main viewer uses this so chained ECM modules can inherit the combined
---- width of Blizzard icons plus any appended extra icons.
----@param viewerKey string
----@param blizzFrame Frame|nil
----@param rightFrame Frame|nil
-function ExtraIcons:_updateViewerAnchor(viewerKey, blizzFrame, rightFrame)
- local vs = self._viewers and self._viewers[viewerKey]
- local anchorFrame = vs and vs.anchorFrame
- if not anchorFrame then
- return
- end
+--- Updates the main viewer's logical anchor frame so chained ECM modules
+--- inherit the combined width of Blizzard icons plus appended extra icons.
+function ExtraIcons:_updateMainAnchor(blizzFrame, rightFrame)
+ local vs = self._viewers and self._viewers.main
+ local anchor = vs and vs.anchorFrame
+ if not anchor then return end
if not blizzFrame or not blizzFrame:IsShown() then
- anchorFrame:Hide()
+ anchor:Hide()
return
end
- local rightAnchor = rightFrame and rightFrame:IsShown() and rightFrame or blizzFrame
-
- anchorFrame:ClearAllPoints()
- anchorFrame:SetPoint("LEFT", blizzFrame, "LEFT", 0, 0)
- anchorFrame:SetPoint("RIGHT", rightAnchor, "RIGHT", 0, 0)
- anchorFrame:SetPoint("TOP", blizzFrame, "TOP", 0, 0)
- anchorFrame:SetPoint("BOTTOM", blizzFrame, "BOTTOM", 0, 0)
- anchorFrame:Show()
+ local right = rightFrame and rightFrame:IsShown() and rightFrame or blizzFrame
+ anchor:ClearAllPoints()
+ anchor:SetPoint("LEFT", blizzFrame, "LEFT", 0, 0)
+ anchor:SetPoint("RIGHT", right, "RIGHT", 0, 0)
+ anchor:SetPoint("TOP", blizzFrame, "TOP", 0, 0)
+ anchor:SetPoint("BOTTOM", blizzFrame, "BOTTOM", 0, 0)
+ anchor:Show()
end
---- Gets the effective chain anchor for the main viewer.
----@return Frame|nil
function ExtraIcons:GetMainViewerAnchor()
local vs = self._viewers and self._viewers.main
- local anchorFrame = vs and vs.anchorFrame
- if anchorFrame and anchorFrame:IsShown() then
- return anchorFrame
- end
-
- return _G[VIEWER_REGISTRY.main.blizzFrameKey]
+ local anchor = vs and vs.anchorFrame
+ if anchor and anchor:IsShown() then return anchor end
+ return _G[BLIZZ_KEY.main]
end
---- Lays out icons for a single viewer.
----@param viewerKey string The viewer key ("utility" or "main").
----@param entries table[] The config entries for this viewer.
----@param isEditing boolean Whether edit mode is active.
----@return boolean changed Whether any icons were placed.
-function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing)
- local reg = VIEWER_REGISTRY[viewerKey]
- local blizzFrame = _G[reg.blizzFrameKey]
+function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
+ local blizzFrame = _G[BLIZZ_KEY[viewerKey]]
local vs = self._viewers[viewerKey]
- if not vs then
- return false
- end
+ if not vs then return false end
local container = vs.container
- cacheOriginalPoint(vs, blizzFrame)
-
- local sharedOffsetX = 0
- if viewerKey == "utility" and vs.originalPoint then
- local mainBlizzFrame = _G[VIEWER_REGISTRY.main.blizzFrameKey]
- local isAnchoredToMainViewer = mainBlizzFrame and vs.originalPoint[2] == mainBlizzFrame or false
- if not isAnchoredToMainViewer then
- sharedOffsetX = self._mainViewerOffsetX or 0
- end
- end
+ sharedOffsetX = sharedOffsetX or 0
+ cachePoint(vs, blizzFrame)
- -- Resolve entries to displayable items
- local items
- if not blizzFrame or not blizzFrame:IsShown() or isEditing or #entries == 0 then
- items = {}
- else
- items = resolveViewerEntries(entries)
- end
+ local items = (not blizzFrame or not blizzFrame:IsShown() or isEditing or #entries == 0)
+ and {} or resolveEntries(entries)
if #items == 0 then
- -- Restore viewer position and hide container
- applyViewerPoint(vs, blizzFrame, sharedOffsetX)
- if isEditing then
- vs.originalPoint = nil
- end
+ applyPoint(vs, blizzFrame, sharedOffsetX)
+ if isEditing then vs.originalPoint = nil end
container:Hide()
- if viewerKey == "main" then
- self:_updateViewerAnchor(viewerKey, blizzFrame, nil)
- end
+ if viewerKey == "main" then self:_updateMainAnchor(blizzFrame, nil) end
return false
end
- -- Hide all existing pool icons
- for _, icon in ipairs(vs.iconPool) do
- icon:Hide()
+ for _, icon in ipairs(vs.iconPool) do icon:Hide() end
+ for i = #vs.iconPool + 1, #items do
+ vs.iconPool[i] = createIcon(container, DEFAULT_SIZE)
end
- -- Ensure pool is large enough
- ensurePoolSize(vs, #items)
-
- local siblingFontPath, siblingFontSize, siblingFontFlags = getSiblingCooldownNumberFont(blizzFrame)
- local iconSize = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
- local spacing = 0
- local viewerScale = 1.0
- local lastActiveItemFrame = nil
- local numActiveViewerIcons = 0
-
- if blizzFrame:IsShown() then
- viewerScale = blizzFrame.iconScale or 1.0
- spacing = blizzFrame.childXPadding or 0
-
- if blizzFrame.GetItemFrames then
- for _, itemFrame in ipairs(blizzFrame:GetItemFrames()) do
- if itemFrame.isActive then
- iconSize = itemFrame:GetWidth() or iconSize
- lastActiveItemFrame = itemFrame
- numActiveViewerIcons = numActiveViewerIcons + 1
- end
+ local fontPath, fontSize, fontFlags = getSiblingFont(blizzFrame)
+ local iconSize = DEFAULT_SIZE
+ local viewerScale = blizzFrame.iconScale or 1.0
+ local spacing = blizzFrame.childXPadding or 0
+ local lastActive = nil
+
+ if blizzFrame.GetItemFrames then
+ for _, itemFrame in ipairs(blizzFrame:GetItemFrames()) do
+ if itemFrame.isActive then
+ iconSize = itemFrame:GetWidth() or iconSize
+ lastActive = itemFrame
end
- else
- for _, child in ipairs({ blizzFrame:GetChildren() }) do
- if child and child:IsShown() and child.GetSpellID then
- iconSize = child:GetWidth() or iconSize
- break
- end
+ end
+ else
+ for _, child in ipairs({ blizzFrame:GetChildren() }) do
+ if child and child:IsShown() and child.GetSpellID then
+ iconSize = child:GetWidth() or iconSize
+ break
end
end
end
container:SetScale(viewerScale)
- local numItems = #items
- local totalWidth = numItems * iconSize + (numItems - 1) * spacing
+ local totalWidth = #items * iconSize + (#items - 1) * spacing
container:SetSize(totalWidth, iconSize)
- if not vs.originalPoint then
- local point, relativeTo, relativePoint, x, y = blizzFrame:GetPoint()
- vs.originalPoint = { point, relativeTo, relativePoint, x or 0, y or 0 }
- end
-
- -- Shift the Blizzard viewer left to keep the combined group centred
- local activeContentWidth = numActiveViewerIcons * iconSize + math.max(0, numActiveViewerIcons - 1) * spacing
- local viewerWidth = numActiveViewerIcons > 0 and blizzFrame:GetWidth() or 0
- local viewerOffsetX = (viewerWidth - activeContentWidth - spacing - totalWidth * viewerScale) / 2
- if viewerKey == "main" then
- self._mainViewerOffsetX = viewerOffsetX
- end
- applyViewerPoint(vs, blizzFrame, sharedOffsetX + viewerOffsetX)
+ -- Shift the Blizzard viewer left to keep the combined group centred.
+ -- The Blizzard frame auto-sizes to its scaled active icons, so its
+ -- on-screen centre already coincides with the original anchor; we only
+ -- need to absorb the on-screen width of the gap + extra group.
+ local extraOnScreen = (spacing + totalWidth) * viewerScale
+ applyPoint(vs, blizzFrame, sharedOffsetX - extraOnScreen / 2)
- -- Position and configure each icon
- local borderScale = ns.Constants.EXTRA_ICON_BORDER_SCALE
local xOffset = 0
- for i, iconData in ipairs(items) do
+ for i, data in ipairs(items) do
local icon = vs.iconPool[i]
icon:SetSize(iconSize, iconSize)
icon.Icon:SetSize(iconSize, iconSize)
icon.Mask:SetSize(iconSize, iconSize)
- icon.Border:SetSize(iconSize * borderScale, iconSize * borderScale)
- icon.slotId = iconData.slotId
- icon.itemId = iconData.itemId
- icon.spellId = iconData.spellId
+ icon.Border:SetSize(iconSize * BORDER_SCALE, iconSize * BORDER_SCALE)
+ icon.slotId = data.slotId
+ icon.itemId = data.itemId
+ icon.spellId = data.spellId
- icon.Icon:SetTexture(iconData.texture)
+ icon.Icon:SetTexture(data.texture)
icon:ClearAllPoints()
icon:SetPoint("LEFT", container, "LEFT", xOffset, 0)
icon:Show()
updateIconCooldown(icon)
- if siblingFontPath and siblingFontSize then
- applyCooldownNumberFont(icon, siblingFontPath, siblingFontSize, siblingFontFlags)
+ if fontPath and fontSize then
+ local region = icon.Cooldown:GetRegions()
+ if region and region.IsObjectType and region:IsObjectType("FontString") and region.SetFont then
+ region:SetFont(fontPath, fontSize, fontFlags)
+ end
end
xOffset = xOffset + iconSize + spacing
end
container:ClearAllPoints()
- container:SetPoint("LEFT", lastActiveItemFrame or blizzFrame, "RIGHT", spacing, 0)
+ container:SetPoint("LEFT", lastActive or blizzFrame, "RIGHT", spacing, 0)
container:Show()
- if viewerKey == "main" then
- self:_updateViewerAnchor(viewerKey, blizzFrame, container)
- end
-
+ if viewerKey == "main" then self:_updateMainAnchor(blizzFrame, container) end
return true
end
---- Override UpdateLayout to position icons for all viewers.
----@param why string|nil Reason for layout update.
----@return boolean success Whether any viewer had icons placed.
function ExtraIcons:UpdateLayout(why)
- if not self.InnerFrame or not self._viewers then
- return false
- end
-
- self._mainViewerOffsetX = 0
+ if not self.InnerFrame or not self._viewers then return false end
local shouldShow = self:ShouldShow()
local moduleConfig = self:GetModuleConfig()
local isEditing = self._isEditModeActive
if isEditing == nil then
- local editModeManager = _G.EditModeManagerFrame
- isEditing = editModeManager and editModeManager:IsShown() or false
+ local mgr = _G.EditModeManagerFrame
+ isEditing = mgr and mgr:IsShown() or false
end
- -- When hidden, nil-out viewers so every _updateSingleViewer call gets
- -- empty entries, which restores Blizzard viewer positions and hides
- -- the extra-icon containers.
+ -- When hidden, leave viewers nil so each call gets empty entries, which
+ -- restores Blizzard viewer positions and hides extra-icon containers.
local viewers = shouldShow and moduleConfig and moduleConfig.viewers
+ local offsets = getSharedOffsets(self._viewers)
local anyPlaced = false
- for i = 1, #VIEWER_ORDER do
- local viewerKey = VIEWER_ORDER[i]
- local entries = viewers and viewers[viewerKey] or {}
- local changed = self:_updateSingleViewer(viewerKey, entries, isEditing)
- if changed then
+ for _, v in ipairs(VIEWERS) do
+ local entries = viewers and viewers[v.key] or {}
+ if self:_updateSingleViewer(v.key, entries, isEditing, offsets[v.key]) then
anyPlaced = true
end
end
- -- Manage InnerFrame visibility (not handled by ApplyFramePosition since
- -- ExtraIcons overrides UpdateLayout without calling the base).
+ -- ApplyFramePosition is bypassed because UpdateLayout is overridden, so
+ -- manage InnerFrame visibility here.
if shouldShow then
- if not self.InnerFrame:IsShown() then
- self.InnerFrame:Show()
- end
+ if not self.InnerFrame:IsShown() then self.InnerFrame:Show() end
else
self.InnerFrame:Hide()
end
@@ -602,50 +454,40 @@ function ExtraIcons:UpdateLayout(why)
return anyPlaced
end
---- Override Refresh to update cooldown states on all viewers.
function ExtraIcons:Refresh(why, force)
- if not BarMixin.FrameProto.Refresh(self, why, force) then
- return false
- end
+ if not BarMixin.FrameProto.Refresh(self, why, force) then return false end
+ if not self._viewers then return false end
- if not self._viewers then
- return false
- end
-
- local anyRefreshed = false
+ local refreshed = false
for _, vs in pairs(self._viewers) do
- local container = vs.container
- if container and container:IsShown() then
+ if vs.container and vs.container:IsShown() then
for _, icon in ipairs(vs.iconPool) do
if icon:IsShown() and (icon.slotId or icon.itemId or icon.spellId) then
updateIconCooldown(icon)
end
end
- anyRefreshed = true
+ refreshed = true
end
end
-
- if anyRefreshed then
+ if refreshed then
ns.Log(self.Name, "Refresh complete (" .. (why or "") .. ")")
end
- return anyRefreshed
+ return refreshed
end
--------------------------------------------------------------------------------
--- Event Handlers
+-- Events and hooks
--------------------------------------------------------------------------------
function ExtraIcons:OnBagUpdateCooldown()
- if self._viewers then
- self:ThrottledRefresh("OnBagUpdateCooldown")
- end
+ self:ThrottledRefresh("OnBagUpdateCooldown")
end
function ExtraIcons:OnBagUpdateDelayed()
ns.Runtime.RequestLayout("ExtraIcons:OnBagUpdateDelayed")
end
-function ExtraIcons:OnPlayerEquipmentChanged(_, slotId)
+function ExtraIcons:OnPlayerEquipmentChanged(slotId)
if self._trackedEquipSlots and self._trackedEquipSlots[slotId] then
ns.Runtime.RequestLayout("ExtraIcons:OnPlayerEquipmentChanged")
end
@@ -659,7 +501,7 @@ function ExtraIcons:OnSpellsChanged()
ns.Runtime.RequestLayout("ExtraIcons:OnSpellsChanged")
end
---- Rebuild the set of tracked equipment slots from the current config.
+--- Rebuilds the set of equipment slots referenced by current config.
function ExtraIcons:_rebuildTrackedSlots()
local tracked = {}
local config = self:GetModuleConfig()
@@ -670,9 +512,7 @@ function ExtraIcons:_rebuildTrackedSlots()
local stack = entry.stackKey and BUILTIN_STACKS[entry.stackKey]
local kind = stack and stack.kind or entry.kind
local sid = stack and stack.slotId or entry.slotId
- if kind == "equipSlot" and sid then
- tracked[sid] = true
- end
+ if kind == "equipSlot" and sid then tracked[sid] = true end
end
end
end
@@ -680,78 +520,46 @@ function ExtraIcons:_rebuildTrackedSlots()
self._trackedEquipSlots = tracked
end
---------------------------------------------------------------------------------
--- Hooks
---------------------------------------------------------------------------------
-
---- Hook EditModeManagerFrame to pause layout while edit mode is active.
function ExtraIcons:HookEditMode()
- local editModeManager = _G.EditModeManagerFrame
- if not editModeManager or self._editModeHooked then
- return
- end
-
+ local mgr = _G.EditModeManagerFrame
+ if not mgr or self._editModeHooked then return end
self._editModeHooked = true
- self._isEditModeActive = editModeManager:IsShown()
+ self._isEditModeActive = mgr:IsShown()
- editModeManager:HookScript("OnShow", function()
+ mgr:HookScript("OnShow", function()
self._isEditModeActive = true
if self._viewers then
- for _, vs in pairs(self._viewers) do
- vs.container:Hide()
- end
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ExtraIcons:EnterEditMode")
+ for _, vs in pairs(self._viewers) do vs.container:Hide() end
end
+ if self:IsEnabled() then ns.Runtime.RequestLayout("ExtraIcons:EnterEditMode") end
end)
- editModeManager:HookScript("OnHide", function()
+ mgr:HookScript("OnHide", function()
self._isEditModeActive = false
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ExtraIcons:ExitEditMode")
- end
+ if self:IsEnabled() then ns.Runtime.RequestLayout("ExtraIcons:ExitEditMode") end
end)
end
---- Hook a single Blizzard viewer frame.
----@param viewerKey string The viewer key.
function ExtraIcons:_hookViewer(viewerKey)
- local reg = VIEWER_REGISTRY[viewerKey]
- local blizzFrame = _G[reg.blizzFrameKey]
+ local blizzKey = BLIZZ_KEY[viewerKey]
+ local blizzFrame = _G[blizzKey]
local vs = self._viewers and self._viewers[viewerKey]
- if not blizzFrame or not vs or vs.hooked then
- return
- end
-
+ if not blizzFrame or not vs or vs.hooked then return end
vs.hooked = true
blizzFrame:HookScript("OnShow", function()
ns.Runtime.RequestLayout("ExtraIcons:OnShow")
end)
-
blizzFrame:HookScript("OnHide", function()
- if vs.container then
- vs.container:Hide()
- end
- if vs.anchorFrame then
- vs.anchorFrame:Hide()
- end
- if self:IsEnabled() then
- ns.Runtime.RequestLayout("ExtraIcons:OnHide")
- end
+ if vs.container then vs.container:Hide() end
+ if vs.anchorFrame then vs.anchorFrame:Hide() end
+ if self:IsEnabled() then ns.Runtime.RequestLayout("ExtraIcons:OnHide") end
end)
-
blizzFrame:HookScript("OnSizeChanged", function()
ns.Runtime.RequestLayout("ExtraIcons:OnSizeChanged")
end)
- ns.Log(self.Name, "Hooked " .. reg.blizzFrameKey)
-end
-
---- Hook the UtilityCooldownViewer (backward compat wrapper).
-function ExtraIcons:HookUtilityViewer()
- self:_hookViewer("utility")
+ ns.Log(self.Name, "Hooked " .. blizzKey)
end
--------------------------------------------------------------------------------
@@ -767,19 +575,19 @@ function ExtraIcons:OnEnable()
ns.Runtime.RegisterFrame(self)
self:_rebuildTrackedSlots()
- self:RegisterEvent("BAG_UPDATE_COOLDOWN", function(_, ...) self:OnBagUpdateCooldown(...) end)
- self:RegisterEvent("BAG_UPDATE_DELAYED", function(_, ...) self:OnBagUpdateDelayed(...) end)
- self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED", function(_, ...) self:OnPlayerEquipmentChanged(...) end)
- self:RegisterEvent("PLAYER_ENTERING_WORLD", function(_, ...) self:OnPlayerEnteringWorld(...) end)
+ self:RegisterEvent("BAG_UPDATE_COOLDOWN", function() self:OnBagUpdateCooldown() end)
+ self:RegisterEvent("BAG_UPDATE_DELAYED", function() self:OnBagUpdateDelayed() end)
+ self:RegisterEvent("PLAYER_EQUIPMENT_CHANGED", function(_, slotId) self:OnPlayerEquipmentChanged(slotId) end)
+ self:RegisterEvent("PLAYER_ENTERING_WORLD", function() self:OnPlayerEnteringWorld() end)
self:RegisterEvent("SPELLS_CHANGED", function() self:OnSpellsChanged() end)
- self:RegisterEvent("SPELL_UPDATE_COOLDOWN", function() self:ThrottledRefresh("OnSpellUpdateCooldown") end)
+ self:RegisterEvent("SPELL_UPDATE_COOLDOWN", function()
+ self:ThrottledRefresh("OnSpellUpdateCooldown")
+ end)
- -- Hook viewers after a short delay to ensure Blizzard frames are loaded
+ -- Hook viewers after a short delay to ensure Blizzard frames are loaded.
C_Timer.After(0.1, function()
self:HookEditMode()
- for viewerKey in pairs(VIEWER_REGISTRY) do
- self:_hookViewer(viewerKey)
- end
+ for _, v in ipairs(VIEWERS) do self:_hookViewer(v.key) end
ns.Runtime.RequestLayout("ExtraIcons:OnEnable")
end)
end
@@ -787,15 +595,11 @@ end
function ExtraIcons:OnDisable()
self:UnregisterAllEvents()
self:UpdateLayout("OnDisable")
-
ns.Runtime.UnregisterFrame(self)
if self._viewers then
- for _, vs in pairs(self._viewers) do
- vs.originalPoint = nil
- end
+ for _, vs in pairs(self._viewers) do vs.originalPoint = nil end
end
self._isEditModeActive = nil
- self._mainViewerOffsetX = nil
self._trackedEquipSlots = nil
end
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index c086c3ef..1999ed60 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -115,10 +115,6 @@ describe("ExtraIcons", function()
end)
end
- function mod:HookUtilityViewer()
- self:_hookViewer("utility")
- end
-
function mod:OnDisable()
self:UnregisterAllEvents()
self:UpdateLayout("OnDisable")
@@ -249,6 +245,7 @@ describe("ExtraIcons real source", function()
}
ns = {
Log = function() end,
+ IsDebugEnabled = function() return false end,
BarMixin = {
FrameProto = {
ShouldShow = function()
@@ -424,6 +421,124 @@ describe("ExtraIcons real source", function()
}
end
+ local function makeActiveFrames(count, width)
+ local frames = {}
+ for i = 1, count do
+ local frame = TestHelpers.makeFrame({ shown = true, width = width, height = width })
+ frame.isActive = true
+ frames[i] = frame
+ end
+ return frames
+ end
+
+ local function getPointAnchorOffset(point, width)
+ if point == "LEFT" or point == "TOPLEFT" or point == "BOTTOMLEFT" then
+ return 0
+ elseif point == "RIGHT" or point == "TOPRIGHT" or point == "BOTTOMRIGHT" then
+ return width
+ elseif point == "CENTER" or point == "TOP" or point == "BOTTOM" then
+ return width / 2
+ end
+
+ error("Unsupported point " .. tostring(point))
+ end
+
+ local function getFrameLeft(frame)
+ local point, relativeTo, relativePoint, x = frame:GetPoint(1)
+ local width = frame:GetWidth() or 0
+ local anchorX = x or 0
+
+ if relativeTo and relativeTo ~= UIParent then
+ local relativeLeft = getFrameLeft(relativeTo)
+ local relativeWidth = relativeTo:GetWidth() or 0
+ anchorX = relativeLeft + getPointAnchorOffset(relativePoint, relativeWidth) + (x or 0)
+ end
+
+ return anchorX - getPointAnchorOffset(point, width)
+ end
+
+ local function getViewerRowCenter(viewerFrame, container)
+ local rowWidth = viewerFrame:GetWidth() or 0
+ if container and container:IsShown() then
+ rowWidth = rowWidth + (viewerFrame.childXPadding or 0) + ((container:GetWidth() or 0) * (container.__scale or 1))
+ end
+ return getFrameLeft(viewerFrame) + (rowWidth / 2)
+ end
+
+ local function resolveAnchorTarget(anchorTarget)
+ if anchorTarget == "UIParent" then
+ return UIParent
+ elseif anchorTarget == "main" then
+ return EssentialCooldownViewer
+ elseif anchorTarget == "utility" then
+ return UtilityCooldownViewer
+ end
+ return nil
+ end
+
+ local function assertViewerRowsStayCenteredAfterMove(args)
+ UtilityCooldownViewer.childXPadding = 4
+ UtilityCooldownViewer.iconScale = 1.0
+ UtilityCooldownViewer:SetWidth(args.utilityViewerWidth)
+ UtilityCooldownViewer.GetItemFrames = function()
+ return makeActiveFrames(args.utilityActiveCount, 22)
+ end
+ UtilityCooldownViewer:SetPoint(
+ args.utilityAnchor.point,
+ resolveAnchorTarget(args.utilityAnchor.relativeTo),
+ args.utilityAnchor.relativePoint,
+ args.utilityAnchor.x,
+ args.utilityAnchor.y
+ )
+
+ EssentialCooldownViewer.childXPadding = 4
+ EssentialCooldownViewer.iconScale = 1.0
+ EssentialCooldownViewer:SetWidth(args.mainViewerWidth)
+ EssentialCooldownViewer.GetItemFrames = function()
+ return makeActiveFrames(args.mainActiveCount, 22)
+ end
+ EssentialCooldownViewer:SetPoint(
+ args.mainAnchor.point,
+ resolveAnchorTarget(args.mainAnchor.relativeTo),
+ args.mainAnchor.relativePoint,
+ args.mainAnchor.x,
+ args.mainAnchor.y
+ )
+
+ inventoryItemBySlot[13] = 101
+ inventoryTextureBySlot[13] = "trinket-1"
+ inventorySpellByItem[101] = 9001
+ inventoryItemBySlot[14] = 102
+ inventoryTextureBySlot[14] = "trinket-2"
+ inventorySpellByItem[102] = 9002
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+
+ local config = makeViewersConfig(args.beforeUtility, args.beforeMain)
+ ExtraIcons.GetModuleConfig = function()
+ return config
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("before-move"))
+
+ config.viewers.utility = args.afterUtility
+ config.viewers.main = args.afterMain
+
+ assert.is_true(ExtraIcons:UpdateLayout("after-move"))
+
+ local utilityCenter = getViewerRowCenter(UtilityCooldownViewer, ExtraIcons._viewers.utility.container)
+ local mainCenter = getViewerRowCenter(EssentialCooldownViewer, ExtraIcons._viewers.main.container)
+
+ assert.are.equal(mainCenter, utilityCenter)
+
+ if args.expectUtilityRelativeTo then
+ local _, relativeTo = UtilityCooldownViewer:GetPoint(1)
+ assert.are.equal(resolveAnchorTarget(args.expectUtilityRelativeTo), relativeTo)
+ end
+ end
+
it("requires at least one viewer to be visible in ShouldShow", function()
assert.is_true(ExtraIcons:ShouldShow())
@@ -442,9 +557,9 @@ describe("ExtraIcons real source", function()
ExtraIcons._trackedEquipSlots = { [13] = true, [14] = true }
- ExtraIcons:OnPlayerEquipmentChanged(nil, 1)
- ExtraIcons:OnPlayerEquipmentChanged(nil, 13)
- ExtraIcons:OnPlayerEquipmentChanged(nil, 14)
+ ExtraIcons:OnPlayerEquipmentChanged(1)
+ ExtraIcons:OnPlayerEquipmentChanged(13)
+ ExtraIcons:OnPlayerEquipmentChanged(14)
assert.same({ "ExtraIcons:OnPlayerEquipmentChanged", "ExtraIcons:OnPlayerEquipmentChanged" }, reasons)
end)
@@ -522,16 +637,12 @@ describe("ExtraIcons real source", function()
assert.is_true(#vs.iconPool >= 1)
end)
- it("only refreshes cooldowns when viewers exist", function()
+ it("refreshes cooldowns via ThrottledRefresh", function()
local reasons = {}
function ExtraIcons:ThrottledRefresh(reason)
reasons[#reasons + 1] = reason
end
- ExtraIcons._viewers = nil
- ExtraIcons:OnBagUpdateCooldown()
- assert.same({}, reasons)
-
ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
ExtraIcons:OnBagUpdateCooldown()
assert.same({ "OnBagUpdateCooldown" }, reasons)
@@ -761,7 +872,7 @@ describe("ExtraIcons real source", function()
assert.are.equal("CENTER", point)
assert.are.equal(UIParent, relativeTo)
assert.are.equal("CENTER", relativePoint)
- assert.are.equal(40.75, x)
+ assert.are.equal(40, x)
assert.are.equal(50, y)
end)
@@ -916,6 +1027,155 @@ describe("ExtraIcons real source", function()
assert.is_true(ExtraIcons._viewers.main.container:IsShown())
end)
+ for _, case in ipairs({
+ {
+ name = "keeps same-parent viewer rows centered when utility becomes empty",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 0 },
+ mainAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {
+ { stackKey = "trinket1" },
+ },
+ afterUtility = {},
+ afterMain = {
+ { stackKey = "trinket1" },
+ { stackKey = "healthstones" },
+ },
+ },
+ {
+ name = "keeps same-parent viewer rows centered when main becomes empty",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 0 },
+ mainAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {
+ { stackKey = "trinket1" },
+ },
+ afterUtility = {
+ { stackKey = "healthstones" },
+ { stackKey = "trinket1" },
+ },
+ afterMain = {},
+ },
+ {
+ name = "keeps same-parent viewer rows centered when both viewers still have different ECM counts",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 0 },
+ mainAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ { stackKey = "trinket1" },
+ },
+ beforeMain = {
+ { stackKey = "trinket2" },
+ },
+ afterUtility = {
+ { stackKey = "healthstones" },
+ },
+ afterMain = {
+ { stackKey = "trinket2" },
+ { stackKey = "trinket1" },
+ },
+ },
+ {
+ name = "keeps same-parent viewer rows centered for center anchors when utility becomes empty",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "CENTER", relativeTo = "UIParent", relativePoint = "CENTER", x = 100, y = 0 },
+ mainAnchor = { point = "CENTER", relativeTo = "UIParent", relativePoint = "CENTER", x = 100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {
+ { stackKey = "trinket1" },
+ },
+ afterUtility = {},
+ afterMain = {
+ { stackKey = "trinket1" },
+ { stackKey = "healthstones" },
+ },
+ },
+ {
+ name = "keeps same-parent viewer rows centered for top-left anchors when utility becomes empty",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "TOPLEFT", relativeTo = "UIParent", relativePoint = "TOPLEFT", x = 100, y = -100 },
+ mainAnchor = { point = "TOPLEFT", relativeTo = "UIParent", relativePoint = "TOPLEFT", x = 100, y = -60 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {
+ { stackKey = "trinket1" },
+ },
+ afterUtility = {},
+ afterMain = {
+ { stackKey = "trinket1" },
+ { stackKey = "healthstones" },
+ },
+ },
+ {
+ name = "keeps same-parent viewer rows centered for right anchors when utility becomes empty",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "RIGHT", relativeTo = "UIParent", relativePoint = "RIGHT", x = -100, y = 0 },
+ mainAnchor = { point = "RIGHT", relativeTo = "UIParent", relativePoint = "RIGHT", x = -100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {
+ { stackKey = "trinket1" },
+ },
+ afterUtility = {},
+ afterMain = {
+ { stackKey = "trinket1" },
+ { stackKey = "healthstones" },
+ },
+ },
+ {
+ name = "keeps a utility viewer anchored to main centered without same-parent coupling",
+ utilityViewerWidth = 22,
+ utilityActiveCount = 1,
+ mainViewerWidth = 48,
+ mainActiveCount = 2,
+ utilityAnchor = { point = "LEFT", relativeTo = "main", relativePoint = "LEFT", x = 26, y = -40 },
+ mainAnchor = { point = "LEFT", relativeTo = "UIParent", relativePoint = "LEFT", x = 100, y = 40 },
+ beforeUtility = {
+ { stackKey = "healthstones" },
+ },
+ beforeMain = {},
+ afterUtility = {},
+ afterMain = {
+ { stackKey = "healthstones" },
+ },
+ expectUtilityRelativeTo = "main",
+ },
+ }) do
+ local currentCase = case
+ it(currentCase.name, function()
+ assertViewerRowsStayCenteredAfterMove(currentCase)
+ end)
+ end
+
it("prefers demonic healthstone over the legacy healthstone", function()
local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
utilityIconChild.GetSpellID = function() return 1 end
@@ -994,7 +1254,7 @@ describe("ExtraIcons real source", function()
local _, anchorFrame = ExtraIcons._viewers.utility.container:GetPoint(1)
assert.are.equal(activeFrame, anchorFrame)
local _, _, _, x = UtilityCooldownViewer:GetPoint(1)
- assert.are.equal(100, x)
+ assert.are.equal(88, x)
end)
it("restores the viewer and hides the container when no items are available", function()
From 8843ecb449d95e19d4a2f83f0a4bdac503814072 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:37:11 +1000
Subject: [PATCH 29/53] - Change the section list header style back to header
instead of subheader - Allow defaults button functionality to be modified,
and the button enabled or disabled. - Move "What's new" to the main options.
---
.../Controls/CollectionFrames.lua | 7 +-
Libs/LibSettingsBuilder/Core.lua | 79 +++++++++++++++++--
Libs/LibSettingsBuilder/Utility.lua | 10 ++-
Tests/UI/BuffBarsOptions_spec.lua | 16 ++--
UI/GeneralOptions.lua | 12 ---
UI/Options.lua | 12 +++
UI/SpellColorsPage.lua | 32 ++++----
7 files changed, 120 insertions(+), 48 deletions(-)
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index aff17c7b..b5c3f61f 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -698,7 +698,7 @@ local function ensureSectionHeaderRow(content, headers, sectionKey, title)
row = CreateFrame("Frame", nil, content)
row:SetHeight(28)
- row._title = internal.createSubheaderTitle(row, title)
+ row._title = internal.createHeaderTitle(row, title)
headers[sectionKey] = row
return row
end
@@ -750,8 +750,9 @@ local function refreshSectionedCollection(frame, data)
for _, section in ipairs(sections) do
local sectionKey = section.key or section.name or tostring(_)
- local header = ensureSectionHeaderRow(content, headers, sectionKey, section.title or section.name or "")
- header._title:SetText(section.title or section.name or "")
+ local titleText = section.title or section.name or ""
+ local header = ensureSectionHeaderRow(content, headers, sectionKey, titleText)
+ header._title:SetText(titleText)
header:ClearAllPoints()
header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
header:SetPoint("RIGHT", content, "RIGHT", 0, 0)
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 48ef8058..482d435f 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -64,11 +64,57 @@ lib._pageLifecycleHooked = false
local internal = lib._internal
+--- Returns Blizzard's category-header `Defaults` button if the SettingsPanel
+--- has been created and the settings list is available.
+local function getCategoryDefaultsButton()
+ local settingsList = SettingsPanel and SettingsPanel.GetSettingsList and SettingsPanel:GetSettingsList()
+ local header = settingsList and settingsList.Header
+ return header and header.DefaultsButton or nil
+end
+
+--- Replaces the category-header `Defaults` button click handler with `onClick`
+--- and forces it enabled (or evaluates `enabledPredicate` when supplied) for as
+--- long as the override is active. Returns a restore function the caller must
+--- invoke when the page is hidden so other categories keep Blizzard's default
+--- behavior.
+function internal.installCategoryDefaultsOverride(onClick, enabledPredicate)
+ local button = getCategoryDefaultsButton()
+ if not button then
+ return function() end
+ end
+
+ local originalOnClick = button:GetScript("OnClick")
+ local originalEnabled = button:IsEnabled()
+
+ local function applyEnabled()
+ if enabledPredicate then
+ button:SetEnabled(enabledPredicate() and true or false)
+ else
+ button:SetEnabled(true)
+ end
+ end
+
+ button:SetScript("OnClick", function()
+ if enabledPredicate and not enabledPredicate() then
+ return
+ end
+ onClick()
+ applyEnabled()
+ end)
+ applyEnabled()
+
+ return function()
+ if button:GetScript("OnClick") then
+ button:SetScript("OnClick", originalOnClick)
+ end
+ button:SetEnabled(originalEnabled)
+ end
+end
+
--- Installs one-time hooks on SettingsPanel to fire page-level onShow/onHide
--- callbacks registered through the root/section/page API. Defers automatically if
--- SettingsPanel has not been created yet (Blizzard_Settings loads on demand).
-local function installPageLifecycleHooks()
- if lib._pageLifecycleHooked then
+local function installPageLifecycleHooks() if lib._pageLifecycleHooked then
return
end
@@ -102,16 +148,27 @@ local function installPageLifecycleHooks()
if old then
local cbs = lib._pageLifecycleCallbacks[old]
- if cbs and cbs.onHide then
- cbs.onHide()
+ if cbs then
+ if cbs._defaultsRestore then
+ cbs._defaultsRestore()
+ cbs._defaultsRestore = nil
+ end
+ if cbs.onHide then
+ cbs.onHide()
+ end
end
end
lib._activeLifecycleCategory = category
if category then
local cbs = lib._pageLifecycleCallbacks[category]
- if cbs and cbs.onShow then
- cbs.onShow()
+ if cbs then
+ if cbs.onDefault then
+ cbs._defaultsRestore = internal.installCategoryDefaultsOverride(cbs.onDefault, cbs.onDefaultEnabled)
+ end
+ if cbs.onShow then
+ cbs.onShow()
+ end
end
end
end)
@@ -120,8 +177,14 @@ local function installPageLifecycleHooks()
local active = lib._activeLifecycleCategory
if active then
local cbs = lib._pageLifecycleCallbacks[active]
- if cbs and cbs.onHide then
- cbs.onHide()
+ if cbs then
+ if cbs._defaultsRestore then
+ cbs._defaultsRestore()
+ cbs._defaultsRestore = nil
+ end
+ if cbs.onHide then
+ cbs.onHide()
+ end
end
end
lib._activeLifecycleCategory = nil
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index f9173a9b..44f6fad8 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -82,6 +82,8 @@
---@field rows LibSettingsBuilderRowConfig[] Gets the declarative row array registered on the page.
---@field onShow LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard shows this page.
---@field onHide LibSettingsBuilderPageLifecycleCallback|nil Gets the callback fired when Blizzard hides this page.
+---@field onDefault fun()|nil Gets the callback invoked when the user clicks the Blizzard category-header `Defaults` button while this page is active. When supplied, the library replaces the button's default reset behavior for the duration the page is shown.
+---@field onDefaultEnabled fun(): boolean|nil Gets the predicate that controls whether the `Defaults` button is enabled while this page is active. Defaults to always-enabled when `onDefault` is supplied.
---@field disabled LibSettingsBuilderPredicate|nil Gets the page-level disabled predicate propagated to child rows.
---@field hidden LibSettingsBuilderPredicate|nil Gets the page-level hidden predicate propagated to child rows.
---@field order number|nil Gets the sort order used when a section declares multiple pages.
@@ -643,10 +645,12 @@ local function assertPageMutable(page, sourceName)
end
local function bindPageLifecycle(page)
- if page._onShow or page._onHide then
+ if page._onShow or page._onHide or page._onDefault then
lib._pageLifecycleCallbacks[page._category] = {
onShow = page._onShow,
onHide = page._onHide,
+ onDefault = page._onDefault,
+ onDefaultEnabled = page._onDefaultEnabled,
}
installPageLifecycleHooks()
end
@@ -706,6 +710,8 @@ local function createPage(owner, key, rows, opts)
_name = opts.name,
_onShow = opts.onShow,
_onHide = opts.onHide,
+ _onDefault = opts.onDefault,
+ _onDefaultEnabled = opts.onDefaultEnabled,
_operations = {},
_rowIDs = {},
_registered = false,
@@ -859,6 +865,8 @@ local function registerPageDefinition(owner, pageDef, defaultName)
name = pageDef.name or defaultName,
onShow = pageDef.onShow,
onHide = pageDef.onHide,
+ onDefault = pageDef.onDefault,
+ onDefaultEnabled = pageDef.onDefaultEnabled,
disabled = pageDef.disabled,
hidden = pageDef.hidden,
order = pageDef.order,
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 85d7a1f6..8e7b819b 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -418,7 +418,7 @@ describe("BuffBarsOptions", function()
it("does not add the old configure spell colors shortcut to aura bars", function()
local buttonRows = {}
- for _, row in ipairs(BuffBarsOptions.pages[1].rows) doy
+ for _, row in ipairs(BuffBarsOptions.pages[1].rows) do
if row.type == "button" then
buttonRows[#buttonRows + 1] = row
end
@@ -608,9 +608,8 @@ describe("BuffBarsOptions", function()
ExternalSpellColors:SetColorByKey(externalKey, { r = 0.6, g = 0.5, b = 0.4, a = 1 })
local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local actions = getSpellColorActions(spellColorsSpec)
- actions[3].onClick()
+ spellColorsSpec.onDefault()
assert.are.same(buffResetDefaultColor, BuffSpellColors:GetDefaultColor())
assert.are.same(externalResetDefaultColor, ExternalSpellColors:GetDefaultColor())
@@ -657,13 +656,12 @@ describe("BuffBarsOptions", function()
})
local spellColorsSpec = registerSpellColorsSpec()
- local actions = getSpellColorActions(spellColorsSpec)
local buffHeader = assert(getSpellColorsRow(spellColorsSpec, "buffBarsSpellColorsHeader"))
local externalHeader = assert(getSpellColorsRow(spellColorsSpec, "externalBarsSpellColorsHeader"))
local buffItems = getSpellColorCollectionItems(spellColorsSpec, "buffBars")
local externalItems = getSpellColorCollectionItems(spellColorsSpec, "externalBars")
- assert.is_true(actions[3].enabled())
+ assert.is_true(spellColorsSpec.onDefaultEnabled())
assert.is_false(buffHeader.disabled())
assert.is_true(externalHeader.disabled())
assert.is_true(buffItems[1].color.enabled())
@@ -781,7 +779,7 @@ describe("BuffBarsOptions", function()
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
- assert.is_false(actions[3].enabled())
+ assert.is_false(spellColorsSpec.onDefaultEnabled())
end)
it("header actions re-evaluate live edit locks after page creation", function()
@@ -794,7 +792,7 @@ describe("BuffBarsOptions", function()
assert.is_true(actions[1].enabled())
assert.is_true(actions[2].enabled())
- assert.is_true(actions[3].enabled())
+ assert.is_true(spellColorsSpec.onDefaultEnabled())
ns.Addon.BuffBars.IsEditLocked = function()
return true, "combat"
@@ -805,7 +803,7 @@ describe("BuffBarsOptions", function()
assert.is_false(actions[1].enabled())
assert.is_false(actions[2].enabled())
- assert.is_false(actions[3].enabled())
+ assert.is_false(spellColorsSpec.onDefaultEnabled())
end)
it("reconcile action uses ConfirmReloadUI for incomplete rows", function()
@@ -900,7 +898,7 @@ describe("BuffBarsOptions", function()
actions[1].onClick()
actions[2].onClick()
- actions[3].onClick()
+ spellColorsSpec.onDefault()
assert.is_nil(confirmText)
assert.is_nil(popupKey)
diff --git a/UI/GeneralOptions.lua b/UI/GeneralOptions.lua
index f4eab4be..db5e17d4 100644
--- a/UI/GeneralOptions.lua
+++ b/UI/GeneralOptions.lua
@@ -156,18 +156,6 @@ local AdvancedOptions = {
return not (gc and gc.debug)
end,
},
- {
- type = "header",
- name = L["WHATS_NEW"],
- },
- {
- type = "button",
- name = " ",
- buttonText = L["SHOW_WHATS_NEW"],
- onClick = function()
- ns.Addon:ShowReleasePopup(true)
- end,
- },
{ type = "header", name = L["PERFORMANCE"] },
{
type = "slider",
diff --git a/UI/Options.lua b/UI/Options.lua
index 03546a54..6628f985 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -78,6 +78,18 @@ ns.AboutPage = {
ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
end,
},
+ {
+ type = "header",
+ name = L["WHATS_NEW"],
+ },
+ {
+ type = "button",
+ name = L["WHATS_NEW"],
+ buttonText = L["WHATS_NEW"],
+ onClick = function()
+ ns.Addon:ShowReleasePopup(true)
+ end,
+ },
},
}
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
index 88a979d8..600b356a 100644
--- a/UI/SpellColorsPage.lua
+++ b/UI/SpellColorsPage.lua
@@ -509,20 +509,6 @@ local function createSpellColorPageActionsRow(refreshPage)
removeAllStaleSpellColors(refreshPage)
end,
},
- {
- text = L["RESET"],
- width = SPELL_COLORS_HEADER_BUTTON_WIDTH,
- enabled = function()
- return canResetAnySpellColorSection()
- end,
- onClick = function()
- if not canResetAnySpellColorSection() then
- return
- end
-
- resetAllSpellColors(refreshPage)
- end,
- },
},
}
end
@@ -565,7 +551,7 @@ local function createSpellColorListRow(section, refreshPage)
id = section.key .. "SpellColorCollection",
type = "list",
variant = "swatch",
- height = 260,
+ height = 180,
rowHeight = C.SCROLL_ROW_HEIGHT_COMPACT,
items = function()
return buildSpellColorItems(section, refreshPage)
@@ -670,10 +656,26 @@ end
---@return table
function SpellColorsPage.CreatePage(subcatName)
if not pageSpec then
+ local function refreshRegisteredPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+ end
+
pageSpec = {
key = "spellColors",
name = subcatName,
rows = {},
+ onDefault = function()
+ if not canResetAnySpellColorSection() then
+ return
+ end
+
+ resetAllSpellColors(refreshRegisteredPage)
+ end,
+ onDefaultEnabled = function()
+ return canResetAnySpellColorSection()
+ end,
}
pageSpec.SetRegisteredPage = SpellColorsPage.SetRegisteredPage
SpellColorsPage._pageSpec = pageSpec
From 05c7fcb351160fa94e32083d596a641f52e3d6de Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 25 Apr 2026 00:44:02 +1000
Subject: [PATCH 30/53] fixed the color swatches.. fixed shadowmeld and racials
not appearing. Removed extra icons legend.
---
ARCHITECTURE.md | 6 +-
EnhancedCooldownManager.toc | 3 +-
.../Controls/CollectionFrames.lua | 37 +
Libs/LibSettingsBuilder/Core.lua | 3 +
.../Tests/Collections_spec.lua | 25 +
Libs/LibSettingsBuilder/Utility.lua | 5 +
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 2 +-
Locales/en.lua | 2 -
Tests/TestHelpers.lua | 1 +
Tests/UI/AdvancedOptions_spec.lua | 26 +-
Tests/UI/BuffBarsOptions_spec.lua | 46 +-
Tests/UI/ExternalBarsOptions_spec.lua | 8 +-
Tests/UI/ExtraIconsOptions_spec.lua | 53 +-
Tests/UI/OptionsSections_spec.lua | 6 +-
Tests/UI/Options_spec.lua | 5 +-
UI/AboutOptions.lua | 68 ++
UI/AdvancedOptions.lua | 47 +
UI/ExtraIconsOptions.lua | 800 +++++++++++++++-
UI/ExtraIconsOptionsUtil.lua | 906 ------------------
UI/GeneralOptions.lua | 41 -
UI/Options.lua | 69 --
UI/SpellColorsPage.lua | 22 +-
22 files changed, 1078 insertions(+), 1103 deletions(-)
create mode 100644 UI/AboutOptions.lua
create mode 100644 UI/AdvancedOptions.lua
delete mode 100644 UI/ExtraIconsOptionsUtil.lua
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1919582f..2f4d3f8a 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -203,8 +203,8 @@ The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, whic
Options pages now use LibSettingsBuilder as a single declarative registration tree:
- `SB.GetRoot(L["ADDON_NAME"])` returns the singleton root handle,
-- each options module exports plain section/page spec tables (`ns.GeneralOptions`, `ns.PowerBarOptions`, `ns.AboutPage`, etc.) instead of registering itself,
-- `UI/Options.lua` combines those specs and calls `root:Register({ page = ns.AboutPage, sections = { ... } })` once,
+- each options page has a dedicated `UI/*Options.lua` or `UI/*Page.lua` owner (`UI/AboutOptions.lua`, `UI/AdvancedOptions.lua`, `UI/SpellColorsPage.lua`, etc.) that exports plain section/page spec tables instead of registering itself,
+- `UI/Options.lua` owns only the root SettingsBuilder assembly and calls `root:Register({ page = ns.AboutPage, sections = { ... } })` once,
- `root:Register(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes.
@@ -455,7 +455,7 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Registers through the new root/section/page API and exposes only native controls plus the single viewer-management canvas. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is also synthesized as a disabled placeholder when absent; if race lookup misses, the fallback known-racial scan uses the same spellbook-aware check as runtime resolution so racials like Shadowmeld still surface in the settings list. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the page so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Canvas rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+**Settings UI (`UI/ExtraIconsOptions.lua`):** Registers through the new root/section/page API and exposes only native controls plus the single viewer-management section list. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability, with `ns.ExtraIconsOptionsUtil` retained as the page-local utility namespace for existing callers. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the page so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
### FrameUtil (`ns.FrameUtil`)
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 9e8566ba..32cb3afa 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -49,15 +49,16 @@ Modules\RuneBar.lua
Modules\ExtraIcons.lua
UI\OptionUtil.lua
+UI\AboutOptions.lua
UI\Options.lua
UI\PowerBarTickMarksOptions.lua
UI\GeneralOptions.lua
+UI\AdvancedOptions.lua
UI\LayoutOptions.lua
UI\PowerBarOptions.lua
UI\ResourceBarOptions.lua
UI\RuneBarOptions.lua
UI\ProfileOptions.lua
-UI\ExtraIconsOptionsUtil.lua
UI\ExtraIconsOptions.lua
UI\SpellColorsPage.lua
UI\BuffBarsOptions.lua
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index b5c3f61f..b47da268 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -321,6 +321,42 @@ end
local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
local ACTION_BUTTON_SPACING = 2
+local function ensureActionButtonIcon(button)
+ if button._lsbActionIcon then
+ return button._lsbActionIcon
+ end
+
+ local icon = button:CreateTexture(nil, "ARTWORK")
+ icon:SetPoint("CENTER", button, "CENTER", 0, 0)
+ icon:Hide()
+ button._lsbActionIcon = icon
+ return icon
+end
+
+local function applyActionButtonIcon(button, action, enabled)
+ local icon = button._lsbActionIcon
+ local iconTexture = action and action.iconTexture
+ if not iconTexture then
+ if icon then
+ setTextureValue(icon, nil)
+ icon:Hide()
+ end
+ return
+ end
+
+ icon = ensureActionButtonIcon(button)
+ icon:ClearAllPoints()
+ icon:SetPoint("CENTER", button, "CENTER", 0, 0)
+ icon:SetSize(action.iconSize or 16, action.iconSize or 16)
+ icon:SetAlpha(enabled == false and (action.disabledIconAlpha or 0.35) or (action.iconAlpha or 1))
+ setTextureValue(icon, iconTexture)
+ icon:Show()
+
+ if button.SetText then
+ button:SetText("")
+ end
+end
+
local function ensureActionsCollectionRow(row)
if row._lsbActionsRow then
return
@@ -379,6 +415,7 @@ local function refreshActionsCollectionRow(row, item)
enabled = true
end
applyActionButtonTextures(button, action, enabled)
+ applyActionButtonIcon(button, action, enabled)
if button.SetEnabled then
button:SetEnabled(enabled)
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 482d435f..4cee39e6 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -641,6 +641,9 @@ local function createColorSwatch(parent)
if swatch.RegisterForClicks then
swatch:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
+ if swatch.SetPropagateMouseClicks then
+ swatch:SetPropagateMouseClicks(false)
+ end
return swatch
end
internal.createColorSwatch = createColorSwatch
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index 17f8e45a..eeb383e4 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -113,4 +113,29 @@ describe("LibSettingsBuilder Collections", function()
assert.are.equal("SettingsListElementTemplate", sectionInit._template)
assert.is_function(page.Refresh)
end)
+
+ it("prevents embedded color swatch clicks from selecting the host settings row", function()
+ local created
+ _G.CreateFrame = function()
+ created = {
+ EnableMouse = function(self, enabled)
+ self._mouseEnabled = enabled
+ end,
+ RegisterForClicks = function(self, ...)
+ self._registeredClicks = { ... }
+ end,
+ SetPropagateMouseClicks = function(self, propagate)
+ self._propagateMouseClicks = propagate
+ end,
+ }
+ return created
+ end
+
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local swatch = lsb._internal.createColorSwatch(TestHelpers.makeFrame())
+
+ assert.are.equal(created, swatch)
+ assert.is_true(swatch._mouseEnabled)
+ assert.is_false(swatch._propagateMouseClicks)
+ end)
end)
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 44f6fad8..56ea0f82 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -154,6 +154,11 @@
---@field text string|nil Gets the button label.
---@field width number|nil Gets the button width.
---@field height number|nil Gets the button height.
+---@field buttonTextures table|nil Gets optional full-button texture states.
+---@field iconTexture string|number|nil Gets the optional centered icon texture drawn over the default button chrome.
+---@field iconSize number|nil Gets the optional centered icon size.
+---@field iconAlpha number|nil Gets the optional enabled icon alpha.
+---@field disabledIconAlpha number|nil Gets the optional disabled icon alpha.
---@field enabled boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the enabled predicate or static enabled flag.
---@field hidden boolean|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): boolean|nil)|nil Gets the hidden predicate or static hidden flag.
---@field tooltip string|(fun(action: LibSettingsBuilderPageActionConfig, frame: Frame): string|nil)|nil Gets the tooltip text or tooltip resolver.
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index a5aa4401..ca7a2d50 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -350,7 +350,7 @@ Supported list variants:
- `swatch` — label/icon plus color swatch rows
- `editor` — label plus one or more slider fields, optional swatch, and remove button
- section items use the built-in action-row layout (`up`, `down`, `move`, `delete`)
-- section action buttons may use text or `buttonTextures = { normal, pushed?, disabled?, highlight?, highlightAlpha?, disabledAlpha? }`
+- section action buttons may use text, `iconTexture`, or `buttonTextures = { normal, pushed?, disabled?, highlight?, highlightAlpha?, disabledAlpha? }`
- section trailers support `type = "modeInput"` for toggle + input + preview + submit rows
Mode-input trailer display fields may be static values or functions that are re-evaluated during in-place row refreshes.
diff --git a/Locales/en.lua b/Locales/en.lua
index f4379187..773b7fff 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -254,8 +254,6 @@ L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"] = "Item ID"
L["EXTRA_ICONS_ITEM_LOADING"] = "Loading item..."
L["ADD_ENTRY"] = "Add"
L["EXTRA_ICONS_NO_ENTRIES"] = "No icons configured for this viewer."
-L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"] =
- "Special rows: built-ins use x to hide and + to enable. Custom rows use x to remove. Disabled rows stay listed but are hidden in the real viewers."
L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"] = "The most powerful item in this set will be displayed:"
L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"] = "Toggle between adding a spell ID or an item ID."
L["EXTRA_ICONS_DUPLICATE_ENTRY"] = "Already in %s"
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index cca6c757..8e11a6ff 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -1500,6 +1500,7 @@ function TestHelpers.SetupOptionsEnv(profile, defaults)
ns.ClassUtil = {}
TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, ns)
+ TestHelpers.LoadChunk("UI/AboutOptions.lua", "Unable to load UI/AboutOptions.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
local SB = ns.Settings
diff --git a/Tests/UI/AdvancedOptions_spec.lua b/Tests/UI/AdvancedOptions_spec.lua
index d19040c3..3bd5ef89 100644
--- a/Tests/UI/AdvancedOptions_spec.lua
+++ b/Tests/UI/AdvancedOptions_spec.lua
@@ -9,7 +9,7 @@ local TestHelpers = assert(
describe("AdvancedOptions getters/setters/defaults", function()
local originalGlobals
- local profile, defaults, SB, ns, settings, advancedCategory, initializers
+ local profile, defaults, SB, ns, settings
setup(function()
originalGlobals = TestHelpers.CaptureGlobals(TestHelpers.OPTIONS_GLOBALS)
@@ -25,11 +25,9 @@ describe("AdvancedOptions getters/setters/defaults", function()
SB, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
settings = TestHelpers.CollectSettings(function()
- TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
- local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.AdvancedOptions)
- advancedCategory = page._category
+ TestHelpers.LoadChunk("UI/AdvancedOptions.lua", "AdvancedOptions")(nil, ns)
+ TestHelpers.RegisterSectionSpec(SB, ns.AdvancedOptions)
end)
- initializers = SB._layouts[advancedCategory]._initializers
end)
describe("debug", function()
@@ -64,21 +62,7 @@ describe("AdvancedOptions getters/setters/defaults", function()
end)
end)
- describe("Show What's New button", function()
- it("uses a placeholder label for the button row", function()
- local button = assert(TestHelpers.FindButtonInitializer(initializers, ns.L["SHOW_WHATS_NEW"]))
- assert.are.equal(" ", button._name)
- end)
-
- it("forces the popup open through the addon method", function()
- local forced
- ns.Addon.ShowReleasePopup = function(_, force)
- forced = force
- end
-
- TestHelpers.FindButtonInitializer(initializers, ns.L["SHOW_WHATS_NEW"])._onClick()
-
- assert.is_true(forced)
- end)
+ it("does not register the About page What's New button", function()
+ assert.is_nil(settings["ECM_global_showReleasePopupOnUpdate"])
end)
end)
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 8e7b819b..d4cee8b0 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -721,7 +721,51 @@ describe("BuffBarsOptions", function()
assert.are.same(selectedColor, BuffSpellColors:GetDefaultColor())
assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
+ assert.are.same({}, refreshCalls)
+ end)
+
+ it("does not refresh the spell colors page while applying repeated swatch color changes", function()
+ local buffKey = SpellColors.MakeKey("Repeat Click", 111, 222, 333)
+ local pickedColors = {
+ { r = 0.2, g = 0.3, b = 0.4, a = 1 },
+ { r = 0.7, g = 0.6, b = 0.5, a = 1 },
+ }
+ local pickerCalls, scheduledCalls = 0, 0
+ local appliedSwatchColors = {}
+ local rowFrame = {
+ _swatch = {
+ SetColorRGB = function(_, r, g, b)
+ appliedSwatchColors[#appliedSwatchColors + 1] = { r = r, g = g, b = b }
+ end,
+ },
+ }
+
+ BuffSpellColors:SetColorByKey(buffKey, { r = 1, g = 1, b = 1, a = 1 })
+
+ ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
+ pickerCalls = pickerCalls + 1
+ assert.is_false(hasOpacity)
+ onChange(pickedColors[pickerCalls])
+ end
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledCalls = scheduledCalls + 1
+ assert.are.equal("OptionsChanged", reason)
+ end
+
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local item = getSpellColorCollectionItems(spellColorsSpec, "buffBars")[2]
+
+ item.color.onClick(item, rowFrame)
+ item.color.onClick(item, rowFrame)
+
+ assert.are.equal(2, pickerCalls)
+ assert.are.equal(2, scheduledCalls)
+ assert.are.same(pickedColors[2], BuffSpellColors:GetColorByKey(buffKey))
+ assert.are.same({
+ { r = pickedColors[1].r, g = pickedColors[1].g, b = pickedColors[1].b },
+ { r = pickedColors[2].r, g = pickedColors[2].g, b = pickedColors[2].b },
+ }, appliedSwatchColors)
+ assert.are.same({}, refreshCalls)
end)
it("header actions disable reconcile and remove stale when every row is complete", function()
diff --git a/Tests/UI/ExternalBarsOptions_spec.lua b/Tests/UI/ExternalBarsOptions_spec.lua
index 3d5c9545..3b399db3 100644
--- a/Tests/UI/ExternalBarsOptions_spec.lua
+++ b/Tests/UI/ExternalBarsOptions_spec.lua
@@ -73,9 +73,9 @@ describe("ExternalBarsOptions", function()
assert.are.equal("spellColorsPageActions", sharedPage.rows[1].id)
assert.are.equal("pageActions", sharedPage.rows[1].type)
assert.are.equal("spellColorsDescription", sharedPage.rows[2].id)
- assert.are.equal("externalBarsSpellColorsHeader", sharedPage.rows[3].id)
- assert.are.equal("externalBarsSpellColorsWarning", sharedPage.rows[4].id)
- assert.are.equal("externalBarsSpellColorCollection", sharedPage.rows[5].id)
- assert.are.equal("externalBarsSecretNameDescription", sharedPage.rows[6].id)
+ assert.is_not_nil(getRow(sharedPage.rows, "externalBarsSpellColorsHeader"))
+ assert.is_not_nil(getRow(sharedPage.rows, "externalBarsSpellColorsWarning"))
+ assert.is_not_nil(getRow(sharedPage.rows, "externalBarsSpellColorCollection"))
+ assert.is_not_nil(getRow(sharedPage.rows, "externalBarsSecretNameDescription"))
end)
end)
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 35e0e2db..f6409336 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -43,7 +43,6 @@ describe("ExtraIconsOptions data helpers", function()
CreateModuleEnabledHandler = function() return function() end end,
MakeConfirmDialog = function() return {} end,
}
- TestHelpers.LoadChunk("UI/ExtraIconsOptionsUtil.lua", "ExtraIconsOptionsUtil")(nil, ns)
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ExtraIconsOptions = ns.ExtraIconsOptions
end)
@@ -706,42 +705,30 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.equal("healthstones", rows[3].displayEntry.stackKey)
end)
- it("falls back to known racial spells when UnitRace lookup misses", function()
+ it("matches Shadowmeld from the UnitRace race file token", function()
local viewers = {
utility = {},
main = {},
}
- _G.UnitRace = function() return "Unknown", "Unknown", 99 end
- _G.C_SpellBook = {
- IsSpellKnown = function(spellId)
- return spellId == 59752
- end,
- }
+ _G.UnitRace = function() return "Night Elf", "NightElf", 4 end
local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
assert.are.equal("racialPlaceholder", rows[#rows].rowType)
- assert.are.equal(59752, rows[#rows].spellId)
+ assert.are.equal(58984, rows[#rows].spellId)
end)
- it("falls back to spellbook-known racials for Shadowmeld", function()
+ it("does not synthesize a racial placeholder when UnitRace has no matching race file token", function()
local viewers = {
utility = {},
main = {},
}
- _G.UnitRace = function() return "Unknown", "Unknown", 99 end
- _G.C_SpellBook = {
- IsSpellKnown = function(spellId)
- return spellId == 58984
- end,
- }
-
+ _G.UnitRace = function() return "Night Elf", nil, 4 end
local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
- assert.are.equal("racialPlaceholder", rows[#rows].rowType)
- assert.are.equal(58984, rows[#rows].spellId)
+ assert.are_not.equal("racialPlaceholder", rows[#rows].rowType)
end)
end)
end)
@@ -841,7 +828,6 @@ describe("ExtraIconsOptions settings page", function()
previewCalls[#previewCalls + 1] = active
end
- TestHelpers.LoadChunk("UI/ExtraIconsOptionsUtil.lua", "ExtraIconsOptionsUtil")(nil, ns)
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
capturedPage = ns.ExtraIconsOptions.pages[1]
local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
@@ -893,7 +879,7 @@ describe("ExtraIconsOptions settings page", function()
end))
end)
- it("maps row actions to the addon icon button textures", function()
+ it("maps row actions to built-in button icons", function()
_G.C_Spell = {
GetSpellName = function(spellId)
return spellId == 12345 and "Test Spell" or nil
@@ -911,36 +897,37 @@ describe("ExtraIconsOptions settings page", function()
return item.label == "Test Spell"
end))
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_up_normal",
- custom.actions.up.buttonTextures.normal
+ "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
+ custom.actions.up.iconTexture
)
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\move_down_normal",
- custom.actions.down.buttonTextures.normal
+ "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
+ custom.actions.down.iconTexture
)
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\swap_normal",
- custom.actions.move.buttonTextures.normal
+ "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
+ custom.actions.move.iconTexture
)
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\delete_normal",
- custom.actions.delete.buttonTextures.normal
+ "Interface\\Buttons\\UI-GroupLoot-Pass-Up",
+ custom.actions.delete.iconTexture
)
+ assert.is_nil(custom.actions.delete.buttonTextures)
local activeBuiltin = assert(findItem("utility", function(item)
return item.label == "Healthstones"
end))
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\hide_normal",
- activeBuiltin.actions.delete.buttonTextures.normal
+ "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
+ activeBuiltin.actions.delete.iconTexture
)
local builtinPlaceholder = assert(findItem("utility", function(item)
return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
end))
assert.are.equal(
- "Interface\\AddOns\\EnhancedCooldownManager\\Media\\show_normal",
- builtinPlaceholder.actions.delete.buttonTextures.normal
+ "Interface\\Buttons\\UI-PlusButton-Up",
+ builtinPlaceholder.actions.delete.iconTexture
)
end)
diff --git a/Tests/UI/OptionsSections_spec.lua b/Tests/UI/OptionsSections_spec.lua
index f3e157fb..6da30a27 100644
--- a/Tests/UI/OptionsSections_spec.lua
+++ b/Tests/UI/OptionsSections_spec.lua
@@ -73,6 +73,7 @@ describe("Options root assembly", function()
}
TestHelpers.LoadChunk("UI/OptionUtil.lua", "OptionUtil")(nil, ns)
+ TestHelpers.LoadChunk("UI/AboutOptions.lua", "Unable to load UI/AboutOptions.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
local function placeholderSection(key, name)
@@ -133,6 +134,7 @@ describe("Options root assembly", function()
local _, ns = TestHelpers.SetupOptionsEnv(profile, defaults)
TestHelpers.LoadChunk("UI/GeneralOptions.lua", "GeneralOptions")(nil, ns)
+ TestHelpers.LoadChunk("UI/AdvancedOptions.lua", "AdvancedOptions")(nil, ns)
assert.are.equal("general", ns.GeneralOptions.key)
assert.are.equal(ns.L["GENERAL"], ns.GeneralOptions.name)
@@ -146,7 +148,7 @@ describe("Options root assembly", function()
assert.are.equal(ns.L["ADVANCED_OPTIONS"], ns.AdvancedOptions.name)
assert.are.equal("global", ns.AdvancedOptions.path)
assert.are.equal(1, #ns.AdvancedOptions.pages)
- assert.are.equal(7, #ns.AdvancedOptions.pages[1].rows)
- assert.are.equal("button", ns.AdvancedOptions.pages[1].rows[5].type)
+ assert.are.equal(5, #ns.AdvancedOptions.pages[1].rows)
+ assert.are.equal("slider", ns.AdvancedOptions.pages[1].rows[5].type)
end)
end)
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index 40eeea5f..f60ed17c 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -83,6 +83,7 @@ describe("OptionUtil", function()
end
TestHelpers.LoadChunk("UI/OptionUtil.lua", "Unable to load UI/OptionUtil.lua")(nil, ns)
+ TestHelpers.LoadChunk("UI/AboutOptions.lua", "Unable to load UI/AboutOptions.lua")(nil, ns)
TestHelpers.LoadChunk("UI/Options.lua", "Unable to load UI/Options.lua")(nil, ns)
optionsModule = ns.Addon._modules.Options
end)
@@ -98,7 +99,7 @@ describe("OptionUtil", function()
assert.is_table(registeredPage)
assert.are.equal(ns.L["ADDON_NAME"], registeredPage:GetId())
- assert.are.equal(6, #rows)
+ assert.are.equal(8, #rows)
assert.are.equal("info", rows[1].type)
assert.are.equal("info", rows[2].type)
assert.are.equal("info", rows[3].type)
@@ -106,6 +107,8 @@ describe("OptionUtil", function()
assert.are.equal(ns.L["LINKS"], rows[4].name)
assert.are.equal("button", rows[5].type)
assert.are.equal("button", rows[6].type)
+ assert.are.equal("header", rows[7].type)
+ assert.are.equal("button", rows[8].type)
end)
end)
diff --git a/UI/AboutOptions.lua b/UI/AboutOptions.lua
new file mode 100644
index 00000000..88f1fdbe
--- /dev/null
+++ b/UI/AboutOptions.lua
@@ -0,0 +1,68 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local L = ns.L
+
+local CURSEFORGE_URL = "https://www.curseforge.com/wow/addons/enhanced-cooldown-manager"
+local GITHUB_URL = "https://github.com/argium/EnhancedCooldownManager"
+
+local function getAddonVersion()
+ return (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
+end
+
+ns.AboutPage = {
+ key = "about",
+ rows = {
+ {
+ type = "info",
+ name = L["AUTHOR"],
+ value = function()
+ return ns.ColorUtil.Sparkle("Argi")
+ end,
+ },
+ {
+ type = "info",
+ name = L["CONTRIBUTORS"],
+ value = "kayti-wow",
+ },
+ {
+ type = "info",
+ name = L["VERSION"],
+ value = getAddonVersion,
+ },
+ {
+ type = "subheader",
+ name = L["LINKS"],
+ },
+ {
+ type = "button",
+ name = L["CURSEFORGE"],
+ buttonText = L["CURSEFORGE"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
+ end,
+ },
+ {
+ type = "button",
+ name = L["GITHUB"],
+ buttonText = L["GITHUB"],
+ onClick = function()
+ ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
+ end,
+ },
+ {
+ type = "header",
+ name = L["WHATS_NEW"],
+ },
+ {
+ type = "button",
+ name = L["WHATS_NEW"],
+ buttonText = L["WHATS_NEW"],
+ onClick = function()
+ ns.Addon:ShowReleasePopup(true)
+ end,
+ },
+ },
+}
diff --git a/UI/AdvancedOptions.lua b/UI/AdvancedOptions.lua
new file mode 100644
index 00000000..12bd4704
--- /dev/null
+++ b/UI/AdvancedOptions.lua
@@ -0,0 +1,47 @@
+-- Enhanced Cooldown Manager addon for World of Warcraft
+-- Author: Argium
+-- Licensed under the GNU General Public License v3.0
+
+local _, ns = ...
+local L = ns.L
+
+local AdvancedOptions = {
+ key = "advancedOptions",
+ name = L["ADVANCED_OPTIONS"],
+ path = "global",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { type = "header", name = L["TROUBLESHOOTING"] },
+ {
+ type = "checkbox",
+ path = "debug",
+ name = L["DEBUG_MODE"],
+ tooltip = L["DEBUG_MODE_DESC"],
+ },
+ {
+ type = "checkbox",
+ path = "debugToChat",
+ name = L["DEBUG_TO_CHAT"],
+ tooltip = L["DEBUG_TO_CHAT_DESC"],
+ disabled = function()
+ local gc = ns.GetGlobalConfig()
+ return not (gc and gc.debug)
+ end,
+ },
+ { type = "header", name = L["PERFORMANCE"] },
+ {
+ type = "slider",
+ path = "updateFrequency",
+ name = L["UPDATE_FREQUENCY"],
+ tooltip = L["UPDATE_FREQUENCY_DESC"],
+ min = 0.04,
+ max = 0.5,
+ step = 0.02,
+ },
+ },
+ },
+ },
+}
+ns.AdvancedOptions = AdvancedOptions
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index ee98accd..7480e981 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -3,12 +3,803 @@
-- Licensed under the GNU General Public License v3.0
local _, ns = ...
+local C = ns.Constants
local L = ns.L
+
StaticPopupDialogs["ECM_CONFIRM_REMOVE_EXTRA_ICON"] =
ns.OptionUtil.MakeConfirmDialog(L["REMOVE_ENTRY_CONFIRM"])
+local BUILTIN_STACKS = C.BUILTIN_STACKS
+local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
+local RACIAL_ABILITIES = C.RACIAL_ABILITIES
+
+local VIEWER_COLLECTION_HEIGHT = 448
+local VIEWER_SECTION_HEADER_HEIGHT = 50
+local ACTION_ICON_BUTTON_SIZE = 20
+local DEFAULT_SPECIAL_VIEWER = "utility"
+local VIEWER_ORDER = { "utility", "main" }
+local VIEWER_LABELS = {
+ utility = L["UTILITY_VIEWER_ICONS"],
+ main = L["MAIN_VIEWER_ICONS"],
+}
+local VIEWER_SHORT_LABELS = {
+ utility = L["UTILITY_VIEWER_SHORT"],
+ main = L["MAIN_VIEWER_SHORT"],
+}
+
+local ACTION_BUTTON_ICONS = {
+ delete = "Interface\\Buttons\\UI-GroupLoot-Pass-Up",
+ hide = "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
+ moveDown = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
+ moveLeft = "Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Up",
+ moveRight = "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
+ moveUp = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
+ show = "Interface\\Buttons\\UI-PlusButton-Up",
+}
+local ENABLED_LABEL_COLOR = { 1, 0.82, 0, 1 }
+local DISABLED_LABEL_COLOR = { 0.65, 0.65, 0.65, 1 }
+local DISABLED_ICON_COLOR = { 0.6, 0.6, 0.6, 1 }
+
+local BUILTIN_STACK_SET = {}
+local BUILTIN_EQUIP_SLOTS = {}
+local RACIAL_SPELL_IDS = {}
+for _, key in ipairs(BUILTIN_STACK_ORDER) do
+ BUILTIN_STACK_SET[key] = true
+end
+for _, stack in pairs(BUILTIN_STACKS) do
+ if stack.kind == "equipSlot" and stack.slotId then
+ BUILTIN_EQUIP_SLOTS[stack.slotId] = true
+ end
+end
+for _, racial in pairs(RACIAL_ABILITIES) do
+ RACIAL_SPELL_IDS[racial.spellId] = true
+end
+
local ExtraIconsOptions = ns.ExtraIconsOptions or {}
-local Util = assert(ns.ExtraIconsOptionsUtil, "ExtraIconsOptionsUtil missing")
+local Util = ns.ExtraIconsOptionsUtil or {}
+ns.ExtraIconsOptions = ExtraIconsOptions
+ns.ExtraIconsOptionsUtil = Util
+
+ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
+ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
+local draftStates = ExtraIconsOptions._draftStates
+
+local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
+local registeredPage
+
+for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
+end
+
+Util.IsDisabled = isDisabled
+Util.VIEWER_COLLECTION_HEIGHT = VIEWER_COLLECTION_HEIGHT
+
+local function getProfile() return ns.Addon.db.profile end
+local function getViewers() return getProfile().extraIcons.viewers end
+
+local function refreshPage()
+ if registeredPage then
+ registeredPage:Refresh()
+ end
+end
+
+local function doAction(fn)
+ if fn then
+ fn()
+ end
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ refreshPage()
+end
+
+local function getSpellName(spellId)
+ local api = type(C_Spell) == "table" and C_Spell or nil
+ return spellId and api and api.GetSpellName and api.GetSpellName(spellId) or nil
+end
+
+local function getSpellTexture(spellId)
+ local api = type(C_Spell) == "table" and C_Spell or nil
+ return spellId and api and api.GetSpellTexture and api.GetSpellTexture(spellId) or nil
+end
+
+local function isDisabledBuiltinEntry(entry) return entry and entry.stackKey and entry.disabled and BUILTIN_STACK_SET[entry.stackKey] == true end
+
+local function getEntrySpellId(entry)
+ if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
+ return nil
+ end
+ local first = entry.ids[1]
+ return type(first) == "table" and first.spellId or first
+end
+
+local function getItemIdFromEntry(entry) return type(entry) == "table" and (entry.itemID or entry.itemId) or entry end
+
+local function buildEntry(kind, ids)
+ local entryIds = {}
+ for _, id in ipairs(ids) do
+ entryIds[#entryIds + 1] = kind == "item" and { itemID = getItemIdFromEntry(id) } or id
+ end
+ return { kind = kind, ids = entryIds }
+end
+
+local function getViewerEntries(viewers, viewerKey)
+ local entries = viewers[viewerKey]
+ if entries then
+ return entries
+ end
+ entries = {}
+ viewers[viewerKey] = entries
+ return entries
+end
+
+local function appendViewerEntry(viewers, viewerKey, entry)
+ local entries = getViewerEntries(viewers, viewerKey)
+ entries[#entries + 1] = entry
+end
+
+local function getCurrentRacialSpellId()
+ local _, raceFile = UnitRace("player")
+ local racial = raceFile and RACIAL_ABILITIES[raceFile] or nil
+ return racial and racial.spellId or nil
+end
+
+local function getItemDisplayName(itemId)
+ if not itemId then
+ return nil
+ end
+
+ local name = C_Item.GetItemNameByID(itemId)
+ if name then
+ ExtraIconsOptions._pendingItemLoads[itemId] = nil
+ return name
+ end
+
+ if C_Item.DoesItemExistByID(itemId) then
+ ExtraIconsOptions._pendingItemLoads[itemId] = true
+ C_Item.RequestLoadItemDataByID(itemId)
+ return L["EXTRA_ICONS_ITEM_LOADING"]
+ end
+
+ return "Item " .. tostring(itemId)
+end
+
+local function getEntryTooltipTitle(entry)
+ local name = ExtraIconsOptions._getEntryName(entry)
+ if entry.kind == "spell" then
+ local id = getEntrySpellId(entry)
+ if id then
+ return ("%s (spell ID %s)"):format(name, id)
+ end
+ end
+ if entry.kind == "item" and entry.ids and entry.ids[1] then
+ local id = getItemIdFromEntry(entry.ids[1])
+ if id then
+ return ("%s (item ID %s)"):format(name, id)
+ end
+ end
+ return name
+end
+
+local function getEntryIdentityKey(entry)
+ if not entry then
+ return nil
+ end
+ if entry.stackKey then
+ return "stack:" .. entry.stackKey
+ end
+ if not (entry.kind and entry.ids and #entry.ids > 0) then
+ return nil
+ end
+
+ local parts = { entry.kind }
+ for _, id in ipairs(entry.ids) do
+ if entry.kind == "spell" then
+ parts[#parts + 1] = tostring(type(id) == "table" and id.spellId or id)
+ else
+ parts[#parts + 1] = tostring(getItemIdFromEntry(id))
+ end
+ end
+ return table.concat(parts, ":")
+end
+
+local function findViewerEntry(viewers, predicate, ignoreViewerKey, ignoreIndex)
+ for viewerKey, entries in pairs(viewers) do
+ for index, entry in ipairs(entries) do
+ if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
+ and predicate(entry, viewerKey, index) then
+ return viewerKey, index, entry
+ end
+ end
+ end
+ return nil, nil, nil
+end
+
+local function showRowTooltip(owner, rowData)
+ if not rowData then
+ return
+ end
+
+ local displayEntry = rowData.displayEntry
+ GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1, 1, false)
+
+ local function tip(text)
+ if text and text ~= "" then
+ GameTooltip:AddLine(text, 1, 1, 1, true)
+ end
+ end
+
+ if rowData.isBuiltin and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
+ elseif rowData.isCurrentRacial and rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
+ end
+ if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
+ tip(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
+ end
+
+ local stack = displayEntry.stackKey and BUILTIN_STACKS[displayEntry.stackKey]
+ if stack and stack.kind == "item" and stack.ids and #stack.ids > 0 then
+ tip(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"])
+ for _, itemEntry in ipairs(stack.ids) do
+ local itemId = getItemIdFromEntry(itemEntry)
+ local parts = {}
+ local icon = itemId and C_Item.GetItemIconByID(itemId)
+ if icon then
+ parts[#parts + 1] = CreateTextureMarkup(icon, 64, 64, 14, 14, 0, 1, 0, 1)
+ end
+ parts[#parts + 1] = getItemDisplayName(itemId) or ("Item " .. tostring(itemId))
+ local quality = type(itemEntry) == "table" and itemEntry.quality
+ if quality then
+ parts[#parts + 1] = CreateAtlasMarkup("Professions-Icon-Quality-Tier" .. quality .. "-Small", 14, 14)
+ end
+ tip(table.concat(parts, " "))
+ end
+ end
+
+ GameTooltip:Show()
+end
+
+function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) return ExtraIconsOptions._findDuplicateEntry(viewers, { stackKey = stackKey }) ~= nil end
+
+function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ local stack = stackKey and BUILTIN_STACKS[stackKey]
+ if not stack or stack.kind ~= "equipSlot" then
+ return true
+ end
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ if not itemId then
+ return false
+ end
+ local _, spellId = C_Item.GetItemSpell(itemId)
+ return spellId ~= nil
+end
+
+function ExtraIconsOptions._isRacialPresent(viewers, spellId) return ExtraIconsOptions._findDuplicateEntry(viewers, buildEntry("spell", { spellId })) ~= nil end
+
+function ExtraIconsOptions._isCurrentRacialEntry(entry) return getEntrySpellId(entry) == getCurrentRacialSpellId() end
+
+function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
+ local spellId = getEntrySpellId(entry)
+ if not spellId then
+ return true
+ end
+
+ local currentSpellId = getCurrentRacialSpellId()
+ return not currentSpellId or spellId == currentSpellId or not RACIAL_SPELL_IDS[spellId]
+end
+
+local function shouldShowEntryRow(entry) return ExtraIconsOptions._isRacialForCurrentPlayer(entry) and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) end
+
+function ExtraIconsOptions._getEntryName(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then
+ return entry.stackKey
+ end
+ if stack.kind == "equipSlot" then
+ local itemId = GetInventoryItemID("player", stack.slotId)
+ local itemName = itemId and getItemDisplayName(itemId)
+ if itemName then
+ return ("%s [%s]"):format(stack.label, itemName)
+ end
+ end
+ return stack.label
+ end
+
+ if entry.kind == "spell" and entry.ids then
+ local spellId = getEntrySpellId(entry)
+ return getSpellName(spellId) or ("Spell " .. tostring(spellId))
+ end
+
+ if entry.kind == "item" and entry.ids then
+ return getItemDisplayName(getItemIdFromEntry(entry.ids[1]))
+ end
+
+ return "Unknown"
+end
+
+function ExtraIconsOptions._getEntryIcon(entry)
+ if entry.stackKey then
+ local stack = BUILTIN_STACKS[entry.stackKey]
+ if not stack then
+ return nil
+ end
+ if stack.kind == "equipSlot" then
+ return GetInventoryItemTexture("player", stack.slotId)
+ end
+ if stack.ids and stack.ids[1] then
+ local itemId = getItemIdFromEntry(stack.ids[1])
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+ return nil
+ end
+
+ if entry.kind == "spell" then
+ return getSpellTexture(getEntrySpellId(entry))
+ end
+
+ if entry.kind == "item" and entry.ids then
+ local itemId = getItemIdFromEntry(entry.ids[1])
+ return itemId and C_Item.GetItemIconByID(itemId)
+ end
+
+ return nil
+end
+
+function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+ local viewers = profile.extraIcons.viewers
+ if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then appendViewerEntry(viewers, viewerKey, { stackKey = stackKey }) end
+end
+
+function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ local viewers = profile.extraIcons.viewers
+ if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then appendViewerEntry(viewers, viewerKey, buildEntry("spell", { spellId })) end
+end
+
+function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
+ local viewers = profile.extraIcons.viewers
+ local entry = buildEntry(kind, ids)
+ if not ExtraIconsOptions._isDuplicateEntry(viewers, entry) then appendViewerEntry(viewers, viewerKey, entry) end
+end
+
+function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if entries and index >= 1 and index <= #entries then table.remove(entries, index) end
+end
+
+function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ local entry = entries and entries[index]
+ if entry then entry.disabled = disabled and true or nil end
+end
+
+function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
+ if not index then
+ ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
+ return
+ end
+
+ local entry = (profile.extraIcons.viewers[viewerKey] or {})[index]
+ if entry then entry.disabled = not entry.disabled and true or nil end
+end
+
+function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
+ if index then
+ ExtraIconsOptions._removeEntry(profile, viewerKey, index)
+ return
+ end
+ if spellId then
+ ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ end
+end
+
+function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
+ local entries = profile.extraIcons.viewers[viewerKey]
+ if not entries then
+ return
+ end
+
+ local visibleIndices, activeIndex = {}, nil
+ for i, entry in ipairs(entries) do
+ if not isDisabledBuiltinEntry(entry) and shouldShowEntryRow(entry) then
+ visibleIndices[#visibleIndices + 1] = i
+ if i == index then
+ activeIndex = #visibleIndices
+ end
+ end
+ end
+
+ if not activeIndex then
+ return
+ end
+
+ local target = visibleIndices[activeIndex + direction]
+ if target then
+ entries[index], entries[target] = entries[target], entries[index]
+ end
+end
+
+function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
+ local viewers = profile.extraIcons.viewers
+ local from = viewers[fromViewer]
+ if not from or index < 1 or index > #from then
+ return
+ end
+ if ExtraIconsOptions._findDuplicateEntry(viewers, from[index], fromViewer, index) == toViewer then
+ return
+ end
+
+ local entry = table.remove(from, index)
+ appendViewerEntry(viewers, toViewer, entry)
+end
+
+function ExtraIconsOptions._otherViewer(viewerKey) return viewerKey == "utility" and "main" or "utility" end
+
+function ExtraIconsOptions._parseSingleId(text)
+ if not text or text == "" then
+ return nil
+ end
+ local num = tonumber(text)
+ if not num or num <= 0 or num ~= math.floor(num) then
+ return nil
+ end
+ return num
+end
+
+function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
+ local id = ExtraIconsOptions._parseSingleId(text)
+ if not id then
+ return "invalid", nil, nil
+ end
+
+ if kind == "spell" then
+ local name = getSpellName(id)
+ return name and "resolved" or "invalid", name, name and getSpellTexture(id) or nil
+ end
+
+ if kind == "item" then
+ if not C_Item.DoesItemExistByID(id) then
+ return "invalid", nil, nil
+ end
+
+ local name = C_Item.GetItemNameByID(id)
+ local icon = C_Item.GetItemIconByID(id)
+ if name then
+ ExtraIconsOptions._pendingItemLoads[id] = nil
+ return "resolved", name, icon
+ end
+
+ ExtraIconsOptions._pendingItemLoads[id] = true
+ C_Item.RequestLoadItemDataByID(id)
+ return "pending", nil, icon
+ end
+
+ return "invalid", nil, nil
+end
+
+function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
+ local candidateKey = getEntryIdentityKey(candidateEntry)
+ if not candidateKey then
+ return nil, nil
+ end
+
+ return findViewerEntry(viewers, function(entry)
+ return getEntryIdentityKey(entry) == candidateKey
+ end, ignoreViewerKey, ignoreIndex)
+end
+
+function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) return ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) ~= nil end
+
+local function makeRowData(rowType, viewerKey, displayEntry, index)
+ local isPlaceholder = rowType ~= "entry"
+ return {
+ rowType = rowType,
+ viewerKey = viewerKey,
+ index = index,
+ stackKey = displayEntry.stackKey,
+ spellId = getEntrySpellId(displayEntry),
+ displayEntry = displayEntry,
+ isBuiltin = displayEntry.stackKey ~= nil,
+ isCurrentRacial = rowType == "racialPlaceholder" or ExtraIconsOptions._isCurrentRacialEntry(displayEntry),
+ isPlaceholder = isPlaceholder,
+ isDisabled = isPlaceholder or displayEntry.disabled == true,
+ }
+end
+
+function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
+ local activeRows, disabledBuiltinRows = {}, {}
+ for index, entry in ipairs(viewers[viewerKey] or {}) do
+ if shouldShowEntryRow(entry) then
+ local rowData = makeRowData("entry", viewerKey, entry, index)
+ if isDisabledBuiltinEntry(entry) then
+ local bucket = disabledBuiltinRows[entry.stackKey] or {}
+ disabledBuiltinRows[entry.stackKey] = bucket
+ bucket[#bucket + 1] = rowData
+ else
+ activeRows[#activeRows + 1] = rowData
+ end
+ end
+ end
+
+ for i, rowData in ipairs(activeRows) do
+ rowData.activeIndex = i
+ rowData.activeCount = #activeRows
+ end
+
+ local rows = activeRows
+ for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
+ local bucket = disabledBuiltinRows[stackKey]
+ if bucket then
+ for _, rowData in ipairs(bucket) do
+ rows[#rows + 1] = rowData
+ end
+ elseif viewerKey == DEFAULT_SPECIAL_VIEWER
+ and ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
+ and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
+ rows[#rows + 1] = makeRowData("builtinPlaceholder", viewerKey, { stackKey = stackKey })
+ end
+ end
+
+ if viewerKey == DEFAULT_SPECIAL_VIEWER then
+ local racialSpellId = getCurrentRacialSpellId()
+ if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
+ rows[#rows + 1] = makeRowData("racialPlaceholder", viewerKey, buildEntry("spell", { racialSpellId }))
+ end
+ end
+
+ return rows
+end
+
+function Util.SetRegisteredPage(page) registeredPage = page end
+
+function Util.EnsureItemLoadFrame()
+ local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
+ if not itemLoadFrame then
+ itemLoadFrame = CreateFrame("Frame")
+ ExtraIconsOptions._itemLoadFrame = itemLoadFrame
+ end
+ if itemLoadFrame._ecmHooked then
+ return
+ end
+
+ itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
+ itemLoadFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
+ itemLoadFrame:SetScript("OnEvent", function(_, event, arg1)
+ if event == "GET_ITEM_INFO_RECEIVED" and arg1 and ExtraIconsOptions._pendingItemLoads[arg1] then
+ ExtraIconsOptions._pendingItemLoads[arg1] = nil
+ refreshPage()
+ elseif event == "PLAYER_EQUIPMENT_CHANGED" and BUILTIN_EQUIP_SLOTS[arg1] then
+ refreshPage()
+ end
+ end)
+ itemLoadFrame._ecmHooked = true
+end
+
+local function getDraftDuplicateInfo(viewerKey)
+ local ds = draftStates[viewerKey]
+ local id = ExtraIconsOptions._parseSingleId(ds.idText)
+ if not id then
+ return false, nil
+ end
+ local dupViewer = ExtraIconsOptions._findDuplicateEntry(getViewers(), buildEntry(ds.kind, { id }))
+ return dupViewer ~= nil, dupViewer
+end
+
+local function addDraftEntry(viewerKey)
+ local ds = draftStates[viewerKey]
+ local status = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ local isDuplicate = getDraftDuplicateInfo(viewerKey)
+ if status ~= "resolved" or isDuplicate then
+ return false
+ end
+
+ local id = ExtraIconsOptions._parseSingleId(ds.idText)
+ ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, ds.kind, { id })
+ ds.idText = ""
+ doAction()
+ return true
+end
+
+local function makeAction(text, iconTexture, enabled, tooltip, onClick)
+ return {
+ text = text,
+ width = ACTION_ICON_BUTTON_SIZE,
+ height = ACTION_ICON_BUTTON_SIZE,
+ iconTexture = iconTexture,
+ enabled = enabled,
+ tooltip = tooltip,
+ onClick = onClick,
+ }
+end
+
+local function profileAction(fn)
+ return function()
+ doAction(function()
+ fn(getProfile())
+ end)
+ end
+end
+
+local function getDeleteAction(rowData, displayEntry, controlsDisabled)
+ if rowData.isBuiltin then
+ return makeAction(
+ rowData.isDisabled and "+" or "x",
+ rowData.isDisabled and ACTION_BUTTON_ICONS.show or ACTION_BUTTON_ICONS.hide,
+ not controlsDisabled,
+ rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"],
+ profileAction(function(profile)
+ ExtraIconsOptions._toggleBuiltinRow(
+ profile,
+ rowData.viewerKey,
+ rowData.index,
+ rowData.stackKey or displayEntry.stackKey
+ )
+ end)
+ )
+ end
+
+ if rowData.isCurrentRacial and rowData.isPlaceholder then
+ return makeAction("+", ACTION_BUTTON_ICONS.show, not controlsDisabled, L["ADD_ENTRY"], profileAction(function(profile)
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, rowData.viewerKey, nil, rowData.spellId)
+ end))
+ end
+
+ return makeAction("x", ACTION_BUTTON_ICONS.delete, not controlsDisabled, L["REMOVE_TOOLTIP"], function()
+ StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
+ onAccept = profileAction(function(profile)
+ ExtraIconsOptions._removeEntry(profile, rowData.viewerKey, rowData.index)
+ end),
+ })
+ end)
+end
+
+local function makeReorderAction(rowData, text, iconTexture, enabled, direction)
+ return makeAction(text, iconTexture, enabled, direction < 0 and L["MOVE_UP_TOOLTIP"] or L["MOVE_DOWN_TOOLTIP"],
+ profileAction(function(profile)
+ ExtraIconsOptions._reorderEntry(profile, rowData.viewerKey, rowData.index, direction)
+ end))
+end
+
+local function getMoveTooltip(hasMoveDup, posLocked, otherViewer)
+ if hasMoveDup then
+ return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(VIEWER_SHORT_LABELS[otherViewer])
+ end
+ if posLocked then
+ return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
+ end
+ return L["MOVE_TO_VIEWER_TOOLTIP"]:format(VIEWER_SHORT_LABELS[otherViewer])
+end
+
+local function buildActionItem(rowData)
+ local controlsDisabled = isDisabled()
+ local displayEntry = rowData.displayEntry
+ local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
+ local dupViewer = rowData.index ~= nil
+ and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index) or nil
+ local hasMoveDup = dupViewer == otherViewer
+ local posLocked = rowData.isBuiltin and rowData.isDisabled
+ local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
+ local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
+ local moveIcon = rowData.viewerKey == "utility" and ACTION_BUTTON_ICONS.moveRight or ACTION_BUTTON_ICONS.moveLeft
+
+ return {
+ label = ExtraIconsOptions._getEntryName(displayEntry),
+ icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
+ alpha = rowData.isDisabled and 0.55 or 1,
+ labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
+ labelColor = rowData.isDisabled and DISABLED_LABEL_COLOR or ENABLED_LABEL_COLOR,
+ iconDesaturated = rowData.isDisabled == true,
+ iconVertexColor = rowData.isDisabled and DISABLED_ICON_COLOR or nil,
+ onEnter = function(owner)
+ showRowTooltip(owner, rowData)
+ end,
+ onLeave = function()
+ GameTooltip_Hide()
+ end,
+ actions = {
+ up = makeReorderAction(rowData, "^", ACTION_BUTTON_ICONS.moveUp, canReorder and rowData.activeIndex > 1, -1),
+ down = makeReorderAction(rowData, "v", ACTION_BUTTON_ICONS.moveDown,
+ canReorder and rowData.activeIndex < rowData.activeCount, 1),
+ move = makeAction(
+ rowData.viewerKey == "utility" and ">" or "<",
+ moveIcon,
+ canMove,
+ function()
+ return getMoveTooltip(hasMoveDup, posLocked, otherViewer)
+ end,
+ profileAction(function(profile)
+ ExtraIconsOptions._moveEntry(profile, rowData.viewerKey, otherViewer, rowData.index)
+ end)
+ ),
+ delete = getDeleteAction(rowData, displayEntry, controlsDisabled),
+ },
+ }
+end
+
+local function buildModeInputTrailer(viewerKey)
+ local ds = draftStates[viewerKey]
+
+ local function getPreviewState()
+ local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
+ local isDup, dupViewer = getDraftDuplicateInfo(viewerKey)
+ return status, name, icon, isDup, dupViewer
+ end
+
+ local function toggleKind()
+ if isDisabled() then return false end
+ ds.kind = ds.kind == "spell" and "item" or "spell"; return true
+ end
+
+ return {
+ type = "modeInput",
+ disabled = isDisabled,
+ modeText = function() return ds.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"] end,
+ modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
+ inputText = function() return ds.idText end,
+ placeholder = function() return ds.kind == "spell" and L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] or L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"] end,
+ previewIcon = function() local _, _, icon = getPreviewState(); return icon end,
+ previewText = function()
+ local status, name, _, isDup, dupViewer = getPreviewState()
+ if status == "resolved" and isDup then
+ return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(VIEWER_SHORT_LABELS[dupViewer])
+ end
+ if status == "resolved" then
+ return name or ""
+ end
+ if status == "pending" then
+ return "..."
+ end
+ return nil
+ end,
+ submitText = L["ADD_ENTRY"],
+ submitTooltip = L["ADD_ENTRY"],
+ submitEnabled = function()
+ local status, _, _, isDup = getPreviewState()
+ return status == "resolved" and not isDup
+ end,
+ onToggleMode = toggleKind,
+ onTextChanged = function(text) ds.idText = text or "" end,
+ onSubmit = function() return not isDisabled() and addDraftEntry(viewerKey) or false end,
+ onTabPressed = toggleKind,
+ }
+end
+
+function Util.BuildSections()
+ local viewers = getViewers()
+ local sections = {}
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ local items = {}
+ for _, rowData in ipairs(ExtraIconsOptions._buildViewerRows(viewers, viewerKey)) do
+ items[#items + 1] = buildActionItem(rowData)
+ end
+ sections[#sections + 1] = {
+ key = viewerKey,
+ title = VIEWER_LABELS[viewerKey],
+ headerHeight = VIEWER_SECTION_HEADER_HEIGHT,
+ items = items,
+ emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
+ footer = buildModeInputTrailer(viewerKey),
+ }
+ end
+ return sections
+end
+
+function Util.ResetToDefaults()
+ local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ if not (defaults and defaults.extraIcons) then
+ return
+ end
+
+ ns.Addon.db.profile.extraIcons = ns.CloneValue(defaults.extraIcons)
+ for _, viewerKey in ipairs(VIEWER_ORDER) do
+ draftStates[viewerKey].kind = "spell"
+ draftStates[viewerKey].idText = ""
+ end
+ doAction()
+end
ExtraIconsOptions.key = "extraIcons"
ExtraIconsOptions.name = L["EXTRA_ICONS"]
@@ -32,11 +823,6 @@ ExtraIconsOptions.pages = {
ctx.page:Refresh()
end,
},
- {
- id = "specialRowsLegend", type = "info", name = "",
- value = L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"],
- wide = true, multiline = true, height = 24,
- },
{
id = "viewers", type = "sectionList", height = Util.VIEWER_COLLECTION_HEIGHT,
disabled = Util.IsDisabled,
@@ -46,5 +832,3 @@ ExtraIconsOptions.pages = {
},
},
}
-
-ns.ExtraIconsOptions = ExtraIconsOptions
diff --git a/UI/ExtraIconsOptionsUtil.lua b/UI/ExtraIconsOptionsUtil.lua
deleted file mode 100644
index ba5d9e33..00000000
--- a/UI/ExtraIconsOptionsUtil.lua
+++ /dev/null
@@ -1,906 +0,0 @@
--- Enhanced Cooldown Manager addon for World of Warcraft
--- Author: Argium
--- Licensed under the GNU General Public License v3.0
-
-local _, ns = ...
-local C = ns.Constants
-local L = ns.L
-
-local BUILTIN_STACKS = C.BUILTIN_STACKS
-local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
-local RACIAL_ABILITIES = C.RACIAL_ABILITIES
-
-local VIEWER_COLLECTION_HEIGHT = 448
-local ACTION_ICON_BUTTON_SIZE = 20
-local DEFAULT_SPECIAL_VIEWER = "utility"
-local VIEWER_ORDER = { "utility", "main" }
-local VIEWER_LABELS = {
- utility = L["UTILITY_VIEWER_ICONS"],
- main = L["MAIN_VIEWER_ICONS"],
-}
-local VIEWER_SHORT_LABELS = {
- utility = L["UTILITY_VIEWER_SHORT"],
- main = L["MAIN_VIEWER_SHORT"],
-}
-
-local ACTION_BUTTON_TEXTURE_BASE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\"
-
-local function makeTexturePair(name)
- return { normal = ACTION_BUTTON_TEXTURE_BASE .. name .. "_normal", pushed = ACTION_BUTTON_TEXTURE_BASE .. name .. "_down" }
-end
-
-local ACTION_BUTTON_TEXTURES = {
- delete = makeTexturePair("delete"),
- hide = makeTexturePair("hide"),
- moveDown = makeTexturePair("move_down"),
- moveUp = makeTexturePair("move_up"),
- show = makeTexturePair("show"),
- swap = makeTexturePair("swap"),
-}
-
-local BUILTIN_STACK_SET = {}
-local BUILTIN_EQUIP_SLOTS = {}
-for _, key in ipairs(BUILTIN_STACK_ORDER) do
- BUILTIN_STACK_SET[key] = true
-end
-for _, stack in pairs(BUILTIN_STACKS) do
- if stack.kind == "equipSlot" and stack.slotId then
- BUILTIN_EQUIP_SLOTS[stack.slotId] = true
- end
-end
-
-local ExtraIconsOptions = ns.ExtraIconsOptions or {}
-local Util = ns.ExtraIconsOptionsUtil or {}
-ns.ExtraIconsOptions = ExtraIconsOptions
-ns.ExtraIconsOptionsUtil = Util
-
-ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
-ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
-local draftStates = ExtraIconsOptions._draftStates
-
-local isDisabled = ns.OptionUtil.GetIsDisabledDelegate("extraIcons")
-local registeredPage
-
-for _, viewerKey in ipairs(VIEWER_ORDER) do
- draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
-end
-
-Util.IsDisabled = isDisabled
-Util.VIEWER_COLLECTION_HEIGHT = VIEWER_COLLECTION_HEIGHT
-
-local function getProfile()
- return ns.Addon.db.profile
-end
-
-local function getViewers()
- return getProfile().extraIcons.viewers
-end
-
-local function refreshPage()
- if registeredPage then
- registeredPage:Refresh()
- end
-end
-
-local function doAction(fn)
- if fn then
- fn()
- end
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- refreshPage()
-end
-
-local function getViewerShortLabel(viewerKey)
- return VIEWER_SHORT_LABELS[viewerKey]
-end
-
-local function getSpellAPI()
- return type(C_Spell) == "table" and C_Spell or nil
-end
-
-local function getSpellName(spellId)
- local api = getSpellAPI()
- return spellId and api and api.GetSpellName and api.GetSpellName(spellId) or nil
-end
-
-local function getSpellTexture(spellId)
- local api = getSpellAPI()
- return spellId and api and api.GetSpellTexture and api.GetSpellTexture(spellId) or nil
-end
-
-local function isDisabledBuiltinEntry(entry)
- return entry and entry.stackKey and entry.disabled and BUILTIN_STACK_SET[entry.stackKey] == true
-end
-
-local function getEntrySpellId(entry)
- if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
- return nil
- end
- local first = entry.ids[1]
- return type(first) == "table" and first.spellId or first
-end
-
-local function getItemIdFromEntry(entry)
- return type(entry) == "table" and (entry.itemID or entry.itemId) or entry
-end
-
-local function buildEntry(kind, ids)
- local entryIds = {}
- for _, id in ipairs(ids) do
- entryIds[#entryIds + 1] = kind == "item" and { itemID = getItemIdFromEntry(id) } or id
- end
- return { kind = kind, ids = entryIds }
-end
-
-local function getViewerEntries(viewers, viewerKey)
- local entries = viewers[viewerKey]
- if entries then
- return entries
- end
- entries = {}
- viewers[viewerKey] = entries
- return entries
-end
-
-local function getCurrentRacialEntry()
- local _, raceFile = UnitRace("player")
- local entry = RACIAL_ABILITIES[raceFile]
- if entry then
- return entry
- end
- for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry.spellId and C_SpellBook.IsSpellKnown(racialEntry.spellId) then
- return racialEntry
- end
- end
- return nil
-end
-
-local function getCurrentRacialSpellId()
- local racial = getCurrentRacialEntry()
- return racial and racial.spellId or nil
-end
-
-local function getItemDisplayName(itemId)
- if not itemId then
- return nil
- end
-
- local name = C_Item.GetItemNameByID(itemId)
- if name then
- ExtraIconsOptions._pendingItemLoads[itemId] = nil
- return name
- end
-
- if C_Item.DoesItemExistByID(itemId) then
- ExtraIconsOptions._pendingItemLoads[itemId] = true
- C_Item.RequestLoadItemDataByID(itemId)
- return L["EXTRA_ICONS_ITEM_LOADING"]
- end
-
- return "Item " .. tostring(itemId)
-end
-
-local function getEntryTooltipTitle(entry)
- local name = ExtraIconsOptions._getEntryName(entry)
- if type(entry) ~= "table" then
- return name
- end
- if entry.kind == "spell" then
- local id = getEntrySpellId(entry)
- if id then
- return ("%s (spell ID %s)"):format(name, id)
- end
- elseif entry.kind == "item" and entry.ids and entry.ids[1] then
- local id = getItemIdFromEntry(entry.ids[1])
- if id then
- return ("%s (item ID %s)"):format(name, id)
- end
- end
- return name
-end
-
-local function getEntryIdentityKey(entry)
- if not entry then
- return nil
- end
- if entry.stackKey then
- return "stack:" .. entry.stackKey
- end
- if not (entry.kind and entry.ids and #entry.ids > 0) then
- return nil
- end
-
- local parts = { entry.kind }
- for _, id in ipairs(entry.ids) do
- if entry.kind == "spell" then
- parts[#parts + 1] = tostring(type(id) == "table" and id.spellId or id)
- else
- parts[#parts + 1] = tostring(getItemIdFromEntry(id))
- end
- end
- return table.concat(parts, ":")
-end
-
-local function showRowTooltip(owner, rowData)
- if not rowData then
- return
- end
-
- local displayEntry = rowData.displayEntry
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- GameTooltip:SetText(getEntryTooltipTitle(displayEntry), 1, 1, 1, 1, false)
-
- local function tip(text)
- if text and text ~= "" then
- GameTooltip:AddLine(text, 1, 1, 1, true)
- end
- end
-
- if rowData.isBuiltin and rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"])
- elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"])
- end
- if rowData.isBuiltin and rowData.isDisabled and not rowData.isPlaceholder then
- tip(L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"])
- end
-
- local stack = displayEntry.stackKey and BUILTIN_STACKS[displayEntry.stackKey]
- if stack and stack.kind == "item" and stack.ids and #stack.ids > 0 then
- tip(L["EXTRA_ICONS_STACK_TOOLTIP_INTRO"])
- for _, itemEntry in ipairs(stack.ids) do
- local itemId = getItemIdFromEntry(itemEntry)
- local parts = {}
- local icon = itemId and C_Item.GetItemIconByID(itemId)
- if icon then
- parts[#parts + 1] = CreateTextureMarkup(icon, 64, 64, 14, 14, 0, 1, 0, 1)
- end
- parts[#parts + 1] = getItemDisplayName(itemId) or ("Item " .. tostring(itemId))
- local quality = type(itemEntry) == "table" and itemEntry.quality
- if quality then
- parts[#parts + 1] = CreateAtlasMarkup("Professions-Icon-Quality-Tier" .. quality .. "-Small", 14, 14)
- end
- tip(table.concat(parts, " "))
- end
- end
-
- GameTooltip:Show()
-end
-
-function ExtraIconsOptions._isStackKeyPresent(viewers, stackKey)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if entry.stackKey == stackKey then
- return true
- end
- end
- end
- return false
-end
-
-function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
- local stack = stackKey and BUILTIN_STACKS[stackKey]
- if not stack or stack.kind ~= "equipSlot" then
- return true
- end
- local itemId = GetInventoryItemID("player", stack.slotId)
- if not itemId then
- return false
- end
- local _, spellId = C_Item.GetItemSpell(itemId)
- return spellId ~= nil
-end
-
-function ExtraIconsOptions._isRacialPresent(viewers, spellId)
- for _, entries in pairs(viewers) do
- for _, entry in ipairs(entries) do
- if getEntrySpellId(entry) == spellId then
- return true
- end
- end
- end
- return false
-end
-
-function ExtraIconsOptions._isCurrentRacialEntry(entry)
- local racialSpellId = getCurrentRacialSpellId()
- return racialSpellId ~= nil and getEntrySpellId(entry) == racialSpellId
-end
-
-function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- local spellId = getEntrySpellId(entry)
- if not spellId then
- return true
- end
-
- local racial = getCurrentRacialEntry()
- if not racial then
- return true
- end
-
- for _, racialEntry in pairs(RACIAL_ABILITIES) do
- if racialEntry ~= racial and spellId == racialEntry.spellId then
- return false
- end
- end
- return true
-end
-
-function ExtraIconsOptions._getEntryName(entry)
- if entry.stackKey then
- local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then
- return entry.stackKey
- end
- if stack.kind == "equipSlot" then
- local itemId = GetInventoryItemID("player", stack.slotId)
- local itemName = itemId and getItemDisplayName(itemId)
- if itemName then
- return ("%s [%s]"):format(stack.label, itemName)
- end
- end
- return stack.label
- end
-
- if entry.kind == "spell" and entry.ids then
- local spellId = getEntrySpellId(entry)
- return getSpellName(spellId) or ("Spell " .. tostring(spellId))
- end
-
- if entry.kind == "item" and entry.ids then
- return getItemDisplayName(getItemIdFromEntry(entry.ids[1]))
- end
-
- return "Unknown"
-end
-
-function ExtraIconsOptions._getEntryIcon(entry)
- if entry.stackKey then
- local stack = BUILTIN_STACKS[entry.stackKey]
- if not stack then
- return nil
- end
- if stack.kind == "equipSlot" then
- return GetInventoryItemTexture("player", stack.slotId)
- end
- if stack.ids and stack.ids[1] then
- local itemId = getItemIdFromEntry(stack.ids[1])
- return itemId and C_Item.GetItemIconByID(itemId)
- end
- return nil
- end
-
- if entry.kind == "spell" then
- return getSpellTexture(getEntrySpellId(entry))
- end
-
- if entry.kind == "item" and entry.ids then
- local itemId = getItemIdFromEntry(entry.ids[1])
- return itemId and C_Item.GetItemIconByID(itemId)
- end
-
- return nil
-end
-
-function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
- local viewers = profile.extraIcons.viewers
- if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- local entries = getViewerEntries(viewers, viewerKey)
- entries[#entries + 1] = { stackKey = stackKey }
- end
-end
-
-function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
- local viewers = profile.extraIcons.viewers
- if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then
- local entries = getViewerEntries(viewers, viewerKey)
- entries[#entries + 1] = buildEntry("spell", { spellId })
- end
-end
-
-function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
- local viewers = profile.extraIcons.viewers
- local entry = buildEntry(kind, ids)
- if not ExtraIconsOptions._isDuplicateEntry(viewers, entry) then
- local entries = getViewerEntries(viewers, viewerKey)
- entries[#entries + 1] = entry
- end
-end
-
-function ExtraIconsOptions._removeEntry(profile, viewerKey, index)
- local entries = profile.extraIcons.viewers[viewerKey]
- if entries and index >= 1 and index <= #entries then
- table.remove(entries, index)
- end
-end
-
-function ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, disabled)
- local entries = profile.extraIcons.viewers[viewerKey]
- local entry = entries and entries[index]
- if entry then
- entry.disabled = disabled and true or nil
- end
-end
-
-function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey)
- if not index then
- ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
- return
- end
-
- local entry = (profile.extraIcons.viewers[viewerKey] or {})[index]
- if entry then
- ExtraIconsOptions._setEntryDisabled(profile, viewerKey, index, not entry.disabled)
- end
-end
-
-function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
- if index then
- ExtraIconsOptions._removeEntry(profile, viewerKey, index)
- return
- end
- if spellId then
- ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
- end
-end
-
-function ExtraIconsOptions._reorderEntry(profile, viewerKey, index, direction)
- local entries = profile.extraIcons.viewers[viewerKey]
- if not entries then
- return
- end
-
- local visibleIndices, activeIndex = {}, nil
- for i, entry in ipairs(entries) do
- if not isDisabledBuiltinEntry(entry)
- and ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
- visibleIndices[#visibleIndices + 1] = i
- if i == index then
- activeIndex = #visibleIndices
- end
- end
- end
-
- if not activeIndex then
- return
- end
-
- local target = visibleIndices[activeIndex + direction]
- if target then
- entries[index], entries[target] = entries[target], entries[index]
- end
-end
-
-function ExtraIconsOptions._moveEntry(profile, fromViewer, toViewer, index)
- local viewers = profile.extraIcons.viewers
- local from = viewers[fromViewer]
- if not from or index < 1 or index > #from then
- return
- end
- if ExtraIconsOptions._findDuplicateEntry(viewers, from[index], fromViewer, index) == toViewer then
- return
- end
-
- local entry = table.remove(from, index)
- local to = getViewerEntries(viewers, toViewer)
- to[#to + 1] = entry
-end
-
-function ExtraIconsOptions._otherViewer(viewerKey)
- return viewerKey == "utility" and "main" or "utility"
-end
-
-function ExtraIconsOptions._parseSingleId(text)
- if not text or text == "" then
- return nil
- end
- local num = tonumber(text)
- if not num or num <= 0 or num ~= math.floor(num) then
- return nil
- end
- return num
-end
-
-function ExtraIconsOptions._resolveDraftEntryPreview(kind, text)
- local id = ExtraIconsOptions._parseSingleId(text)
- if not id then
- return "invalid", nil, nil
- end
-
- if kind == "spell" then
- local name = getSpellName(id)
- if not name then
- return "invalid", nil, nil
- end
- return "resolved", name, getSpellTexture(id)
- end
-
- if kind == "item" then
- if not C_Item.DoesItemExistByID(id) then
- return "invalid", nil, nil
- end
- local name = C_Item.GetItemNameByID(id)
- local icon = C_Item.GetItemIconByID(id)
- if name then
- ExtraIconsOptions._pendingItemLoads[id] = nil
- return "resolved", name, icon
- end
- ExtraIconsOptions._pendingItemLoads[id] = true
- C_Item.RequestLoadItemDataByID(id)
- return "pending", nil, icon
- end
-
- return "invalid", nil, nil
-end
-
-function ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- local candidateKey = getEntryIdentityKey(candidateEntry)
- if not candidateKey then
- return nil, nil
- end
-
- for viewerKey, entries in pairs(viewers) do
- for index, entry in ipairs(entries) do
- if not (viewerKey == ignoreViewerKey and index == ignoreIndex)
- and getEntryIdentityKey(entry) == candidateKey then
- return viewerKey, index
- end
- end
- end
- return nil, nil
-end
-
-function ExtraIconsOptions._isDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex)
- return ExtraIconsOptions._findDuplicateEntry(viewers, candidateEntry, ignoreViewerKey, ignoreIndex) ~= nil
-end
-
-function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
- local activeRows, disabledBuiltinRows = {}, {}
- for index, entry in ipairs(viewers[viewerKey] or {}) do
- if ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) then
- local rowData = {
- rowType = "entry",
- viewerKey = viewerKey,
- index = index,
- displayEntry = entry,
- isBuiltin = entry.stackKey ~= nil,
- isCurrentRacial = ExtraIconsOptions._isCurrentRacialEntry(entry),
- isPlaceholder = false,
- isDisabled = entry.disabled == true,
- }
- if isDisabledBuiltinEntry(entry) then
- local bucket = disabledBuiltinRows[entry.stackKey] or {}
- disabledBuiltinRows[entry.stackKey] = bucket
- bucket[#bucket + 1] = rowData
- else
- activeRows[#activeRows + 1] = rowData
- end
- end
- end
-
- for i, rowData in ipairs(activeRows) do
- rowData.activeIndex = i
- rowData.activeCount = #activeRows
- end
-
- local rows = activeRows
- for _, stackKey in ipairs(BUILTIN_STACK_ORDER) do
- local bucket = disabledBuiltinRows[stackKey]
- if bucket then
- for _, rowData in ipairs(bucket) do
- rows[#rows + 1] = rowData
- end
- elseif viewerKey == DEFAULT_SPECIAL_VIEWER
- and ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
- and not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then
- rows[#rows + 1] = {
- rowType = "builtinPlaceholder",
- viewerKey = viewerKey,
- stackKey = stackKey,
- displayEntry = { stackKey = stackKey },
- isBuiltin = true,
- isCurrentRacial = false,
- isPlaceholder = true,
- isDisabled = true,
- }
- end
- end
-
- if viewerKey == DEFAULT_SPECIAL_VIEWER then
- local racialSpellId = getCurrentRacialSpellId()
- if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
- rows[#rows + 1] = {
- rowType = "racialPlaceholder",
- viewerKey = viewerKey,
- spellId = racialSpellId,
- displayEntry = buildEntry("spell", { racialSpellId }),
- isBuiltin = false,
- isCurrentRacial = true,
- isPlaceholder = true,
- isDisabled = true,
- }
- end
- end
-
- return rows
-end
-
-function Util.SetRegisteredPage(page)
- registeredPage = page
-end
-
-function Util.EnsureItemLoadFrame()
- local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
- if not itemLoadFrame then
- itemLoadFrame = CreateFrame("Frame")
- ExtraIconsOptions._itemLoadFrame = itemLoadFrame
- end
- if itemLoadFrame._ecmHooked then
- return
- end
-
- itemLoadFrame:RegisterEvent("GET_ITEM_INFO_RECEIVED")
- itemLoadFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
- itemLoadFrame:SetScript("OnEvent", function(_, event, arg1)
- if event == "GET_ITEM_INFO_RECEIVED" and arg1 and ExtraIconsOptions._pendingItemLoads[arg1] then
- ExtraIconsOptions._pendingItemLoads[arg1] = nil
- refreshPage()
- elseif event == "PLAYER_EQUIPMENT_CHANGED" and BUILTIN_EQUIP_SLOTS[arg1] then
- refreshPage()
- end
- end)
- itemLoadFrame._ecmHooked = true
-end
-
-local function getDraftDuplicateInfo(viewerKey)
- local ds = draftStates[viewerKey]
- local id = ExtraIconsOptions._parseSingleId(ds.idText)
- if not id then
- return false, nil
- end
- local dupViewer = ExtraIconsOptions._findDuplicateEntry(getViewers(), buildEntry(ds.kind, { id }))
- return dupViewer ~= nil, dupViewer
-end
-
-local function addDraftEntry(viewerKey)
- local ds = draftStates[viewerKey]
- local status = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
- local isDuplicate = getDraftDuplicateInfo(viewerKey)
- if status ~= "resolved" or isDuplicate then
- return false
- end
-
- local id = ExtraIconsOptions._parseSingleId(ds.idText)
- ExtraIconsOptions._addCustomEntry(getProfile(), viewerKey, ds.kind, { id })
- ds.idText = ""
- doAction()
- return true
-end
-
-local function makeAction(text, textures, enabled, tooltip, onClick)
- return {
- text = text,
- width = ACTION_ICON_BUTTON_SIZE,
- height = ACTION_ICON_BUTTON_SIZE,
- buttonTextures = textures,
- enabled = enabled,
- tooltip = tooltip,
- onClick = onClick,
- }
-end
-
-local function buildActionItem(rowData)
- local controlsDisabled = isDisabled()
- local displayEntry = rowData.displayEntry
- local otherViewer = ExtraIconsOptions._otherViewer(rowData.viewerKey)
- local dupViewer = rowData.index ~= nil
- and ExtraIconsOptions._findDuplicateEntry(getViewers(), displayEntry, rowData.viewerKey, rowData.index) or nil
- local hasMoveDup = dupViewer == otherViewer
- local posLocked = rowData.isBuiltin and rowData.isDisabled
- local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
- local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
-
- local delText = "x"
- local delTex = ACTION_BUTTON_TEXTURES.delete
- local delTip = L["REMOVE_TOOLTIP"]
- local delAction = function()
- StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
- onAccept = function()
- doAction(function()
- ExtraIconsOptions._removeEntry(getProfile(), rowData.viewerKey, rowData.index)
- end)
- end,
- })
- end
-
- if rowData.isBuiltin then
- delText = rowData.isDisabled and "+" or "x"
- delTex = rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide
- delTip = rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"]
- delAction = function()
- doAction(function()
- ExtraIconsOptions._toggleBuiltinRow(
- getProfile(),
- rowData.viewerKey,
- rowData.index,
- rowData.stackKey or displayEntry.stackKey
- )
- end)
- end
- elseif rowData.isCurrentRacial and rowData.isPlaceholder then
- delText = "+"
- delTex = ACTION_BUTTON_TEXTURES.show
- delTip = L["ADD_ENTRY"]
- delAction = function()
- doAction(function()
- ExtraIconsOptions._toggleCurrentRacialRow(getProfile(), rowData.viewerKey, nil, rowData.spellId)
- end)
- end
- end
-
- return {
- label = ExtraIconsOptions._getEntryName(displayEntry),
- icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
- alpha = rowData.isDisabled and 0.55 or 1,
- labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
- labelColor = rowData.isDisabled and { 0.65, 0.65, 0.65, 1 } or { 1, 0.82, 0, 1 },
- iconDesaturated = rowData.isDisabled == true,
- iconVertexColor = rowData.isDisabled and { 0.6, 0.6, 0.6, 1 } or nil,
- onEnter = function(owner)
- showRowTooltip(owner, rowData)
- end,
- onLeave = function()
- GameTooltip_Hide()
- end,
- actions = {
- up = makeAction(
- "^",
- ACTION_BUTTON_TEXTURES.moveUp,
- canReorder and rowData.activeIndex > 1,
- L["MOVE_UP_TOOLTIP"],
- function()
- doAction(function()
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, -1)
- end)
- end
- ),
- down = makeAction(
- "v",
- ACTION_BUTTON_TEXTURES.moveDown,
- canReorder and rowData.activeIndex < rowData.activeCount,
- L["MOVE_DOWN_TOOLTIP"],
- function()
- doAction(function()
- ExtraIconsOptions._reorderEntry(getProfile(), rowData.viewerKey, rowData.index, 1)
- end)
- end
- ),
- move = makeAction(
- rowData.viewerKey == "utility" and ">" or "<",
- ACTION_BUTTON_TEXTURES.swap,
- canMove,
- function()
- if hasMoveDup then
- return L["EXTRA_ICONS_DUPLICATE_MOVE_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
- end
- if posLocked then
- return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
- end
- return L["MOVE_TO_VIEWER_TOOLTIP"]:format(getViewerShortLabel(otherViewer))
- end,
- function()
- doAction(function()
- ExtraIconsOptions._moveEntry(getProfile(), rowData.viewerKey, otherViewer, rowData.index)
- end)
- end
- ),
- delete = makeAction(delText, delTex, not controlsDisabled, delTip, delAction),
- },
- }
-end
-
-local function buildModeInputTrailer(viewerKey)
- local ds = draftStates[viewerKey]
-
- local function getPreviewState()
- local status, name, icon = ExtraIconsOptions._resolveDraftEntryPreview(ds.kind, ds.idText)
- local isDup, dupViewer = getDraftDuplicateInfo(viewerKey)
- return status, name, icon, isDup, dupViewer
- end
-
- local function toggleKind()
- if isDisabled() then
- return false
- end
- ds.kind = ds.kind == "spell" and "item" or "spell"
- return true
- end
-
- return {
- type = "modeInput",
- disabled = isDisabled,
- modeText = function()
- return ds.kind == "spell" and L["ADD_SPELL"] or L["ADD_ITEM"]
- end,
- modeTooltip = L["EXTRA_ICONS_DRAFT_TYPE_TOOLTIP"],
- inputText = function()
- return ds.idText
- end,
- placeholder = function()
- return ds.kind == "spell" and L["EXTRA_ICONS_SPELL_ID_PLACEHOLDER"] or L["EXTRA_ICONS_ITEM_ID_PLACEHOLDER"]
- end,
- previewIcon = function()
- local _, _, icon = getPreviewState()
- return icon
- end,
- previewText = function()
- local status, name, _, isDup, dupViewer = getPreviewState()
- if status == "resolved" and isDup then
- return L["EXTRA_ICONS_DUPLICATE_ENTRY"]:format(getViewerShortLabel(dupViewer))
- end
- if status == "resolved" then
- return name or ""
- end
- if status == "pending" then
- return "..."
- end
- return nil
- end,
- submitText = L["ADD_ENTRY"],
- submitTooltip = L["ADD_ENTRY"],
- submitEnabled = function()
- local status, _, _, isDup = getPreviewState()
- return status == "resolved" and not isDup
- end,
- onToggleMode = toggleKind,
- onTextChanged = function(text)
- ds.idText = text or ""
- end,
- onSubmit = function()
- if isDisabled() then
- return false
- end
- return addDraftEntry(viewerKey)
- end,
- onTabPressed = toggleKind,
- }
-end
-
-function Util.BuildSections()
- local viewers = getViewers()
- local sections = {}
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- local items = {}
- for _, rowData in ipairs(ExtraIconsOptions._buildViewerRows(viewers, viewerKey)) do
- items[#items + 1] = buildActionItem(rowData)
- end
- sections[#sections + 1] = {
- key = viewerKey,
- title = VIEWER_LABELS[viewerKey],
- items = items,
- emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
- footer = buildModeInputTrailer(viewerKey),
- }
- end
- return sections
-end
-
-function Util.ResetToDefaults()
- local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
- if not (defaults and defaults.extraIcons) then
- return
- end
-
- ns.Addon.db.profile.extraIcons = ns.CloneValue(defaults.extraIcons)
- for _, viewerKey in ipairs(VIEWER_ORDER) do
- draftStates[viewerKey].kind = "spell"
- draftStates[viewerKey].idText = ""
- end
- doAction()
-end
diff --git a/UI/GeneralOptions.lua b/UI/GeneralOptions.lua
index db5e17d4..73290d11 100644
--- a/UI/GeneralOptions.lua
+++ b/UI/GeneralOptions.lua
@@ -130,44 +130,3 @@ local GeneralOptions = {
},
}
ns.GeneralOptions = GeneralOptions
-
-local AdvancedOptions = {
- key = "advancedOptions",
- name = L["ADVANCED_OPTIONS"],
- path = "global",
- pages = {
- {
- key = "main",
- rows = {
- { type = "header", name = L["TROUBLESHOOTING"] },
- {
- type = "checkbox",
- path = "debug",
- name = L["DEBUG_MODE"],
- tooltip = L["DEBUG_MODE_DESC"],
- },
- {
- type = "checkbox",
- path = "debugToChat",
- name = L["DEBUG_TO_CHAT"],
- tooltip = L["DEBUG_TO_CHAT_DESC"],
- disabled = function()
- local gc = ns.GetGlobalConfig()
- return not (gc and gc.debug)
- end,
- },
- { type = "header", name = L["PERFORMANCE"] },
- {
- type = "slider",
- path = "updateFrequency",
- name = L["UPDATE_FREQUENCY"],
- tooltip = L["UPDATE_FREQUENCY_DESC"],
- min = 0.04,
- max = 0.5,
- step = 0.02,
- },
- },
- },
- },
-}
-ns.AdvancedOptions = AdvancedOptions
diff --git a/UI/Options.lua b/UI/Options.lua
index 6628f985..791bb796 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -5,9 +5,6 @@
local _, ns = ...
local L = ns.L
-local CURSEFORGE_URL = "https://www.curseforge.com/wow/addons/enhanced-cooldown-manager"
-local GITHUB_URL = "https://github.com/argium/EnhancedCooldownManager"
-
--------------------------------------------------------------------------------
-- SettingsBuilder instance
--------------------------------------------------------------------------------
@@ -30,72 +27,6 @@ ns.Settings = LSB:New({
})
ns.SettingsBuilder = ns.Settings
---------------------------------------------------------------------------------
--- About section
---------------------------------------------------------------------------------
-
-local function getAddonVersion()
- return (C_AddOns.GetAddOnMetadata("EnhancedCooldownManager", "Version") or "Unknown"):gsub("^v", "")
-end
-
-ns.AboutPage = {
- key = "about",
- rows = {
- {
- type = "info",
- name = L["AUTHOR"],
- value = function()
- return ns.ColorUtil.Sparkle("Argi")
- end,
- },
- {
- type = "info",
- name = L["CONTRIBUTORS"],
- value = "kayti-wow",
- },
- {
- type = "info",
- name = L["VERSION"],
- value = getAddonVersion,
- },
- {
- type = "subheader",
- name = L["LINKS"],
- },
- {
- type = "button",
- name = L["CURSEFORGE"],
- buttonText = L["CURSEFORGE"],
- onClick = function()
- ns.Addon:ShowCopyTextDialog(CURSEFORGE_URL, L["CURSEFORGE"])
- end,
- },
- {
- type = "button",
- name = L["GITHUB"],
- buttonText = L["GITHUB"],
- onClick = function()
- ns.Addon:ShowCopyTextDialog(GITHUB_URL, L["GITHUB"])
- end,
- },
- {
- type = "header",
- name = L["WHATS_NEW"],
- },
- {
- type = "button",
- name = L["WHATS_NEW"],
- buttonText = L["WHATS_NEW"],
- onClick = function()
- ns.Addon:ShowReleasePopup(true)
- end,
- },
- },
-}
-
---------------------------------------------------------------------------------
--- Options module
---------------------------------------------------------------------------------
local Options = ns.Addon:NewModule("Options")
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
index 600b356a..f46f85cb 100644
--- a/UI/SpellColorsPage.lua
+++ b/UI/SpellColorsPage.lua
@@ -210,6 +210,13 @@ local function refreshSpellColors(refreshPage)
refreshPage()
end
+local function applySpellColorChange(rowFrame, color)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ if rowFrame and rowFrame._swatch and rowFrame._swatch.SetColorRGB then
+ rowFrame._swatch:SetColorRGB(color.r or 1, color.g or 1, color.b or 1)
+ end
+end
+
---@param section table
---@return boolean
local function isSpellColorSectionDisabled(section)
@@ -319,9 +326,8 @@ local function removeStaleSpellColorSection(section)
end
---@param section table
----@param refreshPage fun()
---@return table[]
-local function buildSpellColorItems(section, refreshPage)
+local function buildSpellColorItems(section)
local items = {}
local rows = getSectionSpellColorRows(section)
local spellColors = getSpellColors(section.scope)
@@ -345,16 +351,14 @@ local function buildSpellColorItems(section, refreshPage)
enabled = function()
return not isInteractionDisabled()
end,
- onClick = function()
+ onClick = function(_, rowFrame)
if isInteractionDisabled() then
return
end
ns.OptionUtil.OpenColorPicker(spellColors:GetDefaultColor(), false, function(color)
spellColors:SetDefaultColor(color)
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
+ applySpellColorChange(rowFrame, color)
end)
end,
},
@@ -369,7 +373,7 @@ local function buildSpellColorItems(section, refreshPage)
enabled = function()
return not isInteractionDisabled()
end,
- onClick = function()
+ onClick = function(_, rowFrame)
if isInteractionDisabled() then
return
end
@@ -377,9 +381,7 @@ local function buildSpellColorItems(section, refreshPage)
local current = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor()
ns.OptionUtil.OpenColorPicker(current, false, function(color)
spellColors:SetColorByKey(row.key, color)
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
+ applySpellColorChange(rowFrame, color)
end)
end,
},
From 8cb10a9c826298ad85dc3c059f90d2261ad8bb8e Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 25 Apr 2026 01:37:36 +1000
Subject: [PATCH 31/53] Remove the bad icons.
---
ARCHITECTURE.md | 14 +-
EnhancedCooldownManager.toc | 4 +-
.../Controls/CollectionFrames.lua | 37 ----
Media/delete_down.tga | Bin 1068 -> 0 bytes
Media/delete_normal.tga | Bin 1068 -> 0 bytes
Media/hide_down.tga | Bin 1068 -> 0 bytes
Media/hide_normal.tga | Bin 1068 -> 0 bytes
Media/move_down_down.tga | Bin 1068 -> 0 bytes
Media/move_down_normal.tga | Bin 1068 -> 0 bytes
Media/move_up_down.tga | Bin 1068 -> 0 bytes
Media/move_up_normal.tga | Bin 1068 -> 0 bytes
Media/show_down.tga | Bin 1068 -> 0 bytes
Media/show_normal.tga | Bin 1068 -> 0 bytes
Media/swap_down.tga | Bin 1068 -> 0 bytes
Media/swap_normal.tga | Bin 1068 -> 0 bytes
Modules/ExternalBars.lua | 186 +++++++++++++++++-
Tests/Modules/ExternalBars_spec.lua | 59 ++++++
Tests/UI/BuffBarsOptions_spec.lua | 46 +----
Tests/UI/ExtraIconsOptions_spec.lua | 5 +-
UI/ExtraIconsOptions.lua | 25 +--
UI/Options.lua | 6 +-
UI/SpellColorsPage.lua | 22 +--
22 files changed, 285 insertions(+), 119 deletions(-)
delete mode 100644 Media/delete_down.tga
delete mode 100644 Media/delete_normal.tga
delete mode 100644 Media/hide_down.tga
delete mode 100644 Media/hide_normal.tga
delete mode 100644 Media/move_down_down.tga
delete mode 100644 Media/move_down_normal.tga
delete mode 100644 Media/move_up_down.tga
delete mode 100644 Media/move_up_normal.tga
delete mode 100644 Media/show_down.tga
delete mode 100644 Media/show_normal.tga
delete mode 100644 Media/swap_down.tga
delete mode 100644 Media/swap_normal.tga
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 2f4d3f8a..dbdb0ac9 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -455,7 +455,19 @@ Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the
}
```
-**Settings UI (`UI/ExtraIconsOptions.lua`):** Registers through the new root/section/page API and exposes only native controls plus the single viewer-management section list. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) are exposed on `ns.ExtraIconsOptions` for testability, with `ns.ExtraIconsOptionsUtil` retained as the page-local utility namespace for existing callers. Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows. Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section, but equip-slot placeholders follow the same on-use filter. The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables, and trinket-slot equipment changes refresh the page so the visible rows stay in sync. Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+**Settings UI (`UI/ExtraIconsOptions.lua`):**
+
+Registers through the root/section/page API and exposes only native controls plus the single viewer-management section list. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) and page setup hooks (`SetRegisteredPage`, `EnsureItemLoadFrame`, `BuildSections`, `ResetToDefaults`) are exposed on `ns.ExtraIconsOptions` for testability and options bootstrap.
+
+*Row rendering and add flow.* Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows.
+
+*Built-in rows.* Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section.
+
+*Trinket and equip-slot filtering.* Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Equip-slot placeholders follow the same on-use filter, and trinket-slot equipment changes refresh the page so the visible rows stay in sync.
+
+*Racials.* The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
+
+*Lifecycle.* Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
### FrameUtil (`ns.FrameUtil`)
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 32cb3afa..4161ea6c 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -1,8 +1,8 @@
-## Interface: 120000, 120001, 110207
+## Interface: 120000, 120001, 120005, 110207
## Title: Enhanced Cooldown Manager |cff9c9c9cby|r |cffa855f7A|r|cff7a84f7r|r|cff4cc9f0g|r|cff22c55ei|r
## Notes: Standalone resource bars anchored to Blizzard's Cooldown Manager.
## Author: Argi
-## Version: v0.7.6
+## Version: v0.8.0-beta1
## SavedVariables: EnhancedCooldownManagerDB
## OptionalDeps: Ace3, LibSharedMedia-3.0
## Category-enUS: User Interface
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index b47da268..b5c3f61f 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -321,42 +321,6 @@ end
local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
local ACTION_BUTTON_SPACING = 2
-local function ensureActionButtonIcon(button)
- if button._lsbActionIcon then
- return button._lsbActionIcon
- end
-
- local icon = button:CreateTexture(nil, "ARTWORK")
- icon:SetPoint("CENTER", button, "CENTER", 0, 0)
- icon:Hide()
- button._lsbActionIcon = icon
- return icon
-end
-
-local function applyActionButtonIcon(button, action, enabled)
- local icon = button._lsbActionIcon
- local iconTexture = action and action.iconTexture
- if not iconTexture then
- if icon then
- setTextureValue(icon, nil)
- icon:Hide()
- end
- return
- end
-
- icon = ensureActionButtonIcon(button)
- icon:ClearAllPoints()
- icon:SetPoint("CENTER", button, "CENTER", 0, 0)
- icon:SetSize(action.iconSize or 16, action.iconSize or 16)
- icon:SetAlpha(enabled == false and (action.disabledIconAlpha or 0.35) or (action.iconAlpha or 1))
- setTextureValue(icon, iconTexture)
- icon:Show()
-
- if button.SetText then
- button:SetText("")
- end
-end
-
local function ensureActionsCollectionRow(row)
if row._lsbActionsRow then
return
@@ -415,7 +379,6 @@ local function refreshActionsCollectionRow(row, item)
enabled = true
end
applyActionButtonTextures(button, action, enabled)
- applyActionButtonIcon(button, action, enabled)
if button.SetEnabled then
button:SetEnabled(enabled)
end
diff --git a/Media/delete_down.tga b/Media/delete_down.tga
deleted file mode 100644
index dc8abdb5277edafa6c6ec1012549b41a61cc4b7e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@T@Jxe5Jqo2dF;ENwpy*WTEdeB5KV-HYV5==?8`S5RewsigehqE9o}eLO0`CbCy8B;U79OIZ~olif%d#M6FRCww4t`cx;*Y!fNX}I2B9?al<{9deE
zestw4(P7|@w
v``9zAwI9W)RDr&I2GF|~o*($Qcoy!h7wdLkFTavaw#q&~g6(^@NP6ZUzega<
diff --git a/Media/hide_down.tga b/Media/hide_down.tga
deleted file mode 100644
index ed4550887069b731bb4312ee88839289e802896f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@O%8%U3`QreT(b8M1VKR1=*k0NBr%#O5^v%yyq6d80A8S`>W7me(V67YPU)k}
zVw7qrEK6C+Zev%RFJuBdj_fVhr4j7tZpZe?1fGZg<-F?zM%4UaNL`KV?M^!SJIq(2-_$pq=U
z;jB78=S|>jH9wk@`vyJVB6_6n#S1b4zJ3R|_Aj_U?DN81_&2_+&$HMcvob&AyKQn>
I<(n*34`WpQ&j0`b
diff --git a/Media/hide_normal.tga b/Media/hide_normal.tga
deleted file mode 100644
index f5f7d0c0355c6cf55c763b38b644a42101ac6df2..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmZQzU}As)0R{mE1r8W5scZ3nQZP&(hz99%kIEqlgY+QlRnjv2PYMRv2Xilot)gv=
zfu)qRwjg6
;*RN%44YwQFE?jJM_iLNj{)b^)dO+eZHR$?v&Fue^g3F=i-;pXY%s>c8Tcg+*z
diff --git a/Media/move_down_down.tga b/Media/move_down_down.tga
deleted file mode 100644
index df2e60ca628936bc6fa780611255f61709a45316..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@O%8%U3`QreT(TFHKS4my=*k0VBr%#O5^v%yyq6d80A8S`F^yl!EMSsHXXfk6
zu&FTvBg-+4@rH7$A5ZFocwE`r9-3aTgYL+S$;RvbvEK80^1r-y{iFrEg3eohkdh0C
zhZS?}kHd@vbo5?&v_FY*VsyXl%zfIQ&K88&Jj}K4#RVZYA9L;dNlA#!$6Witd`XDS
z$6Whix+28pW8U(!HHAIUew1&h58~qt@LgAAe%R+Fv(UBvtpB&Z+;&ZMt_TDoo(Q51@n*mp@pmZr
z^Pcnnjc%SXC4*%d%T&uaU2b6-|2=V
z$?}oWzT2OXB+Exe``&O)k}Mw??RUltlBD^vuYG?SQ4zEsgfSHXzPtncE-$!0?DN81
Y$XmbG@44R|qGPg4Hf#5^OjeO+K2aAkC;$Ke
diff --git a/Media/move_up_down.tga b/Media/move_up_down.tga
deleted file mode 100644
index eb5cd3b992e28d4f9cf2464b0be230a7b5764af7..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@OAdlS42CDJT(TDx0YO30=*k0VBr%%!Al}4VcrP#F0lYv>HI2Vz7R)3coj$${
zn;O$Hay;Xipe@TRFiFx}6{)7x+E-U%vZ;^pD5$MH-x``Qey+K)ak{
zU;EuCBgE!oU;Di{C&cDsU;F*xgb%QTJ+owxGzu$F3r*wK`hCyG%epF0t>yArqI7P<*H;sUyXCl|T+
zyd;neoXotLdy*mWLg)pKK^TNlpVj{3)4D(pGrV&5-U&F~9Y?7t!*O&_n8EjWU#w52
z*+0I%kEY;V%&C7GUz$f<)bN>6|LlBjo+h6e^(X1VJWW0`>POki2=3YB^M9&8o#h7V
zJl#W1{rKwEh~|CGjQYv-+C1Lj+Mla`vD}zK53`=BpWYN!2i&@S^|QRRF3|TqfW5Y`
ff8b-V7uwFh*57mc^isT5&(-5Y{B~E}7fJX5OxQB6
diff --git a/Media/show_down.tga b/Media/show_down.tga
deleted file mode 100644
index 1fb5fbcf19163bbf064eac15f2c793bb1c5a1a4e..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@T~5MK6ohYh@{xB?N(-$8T8uuq00>QtCh`+@;x6158*u?{;E<_~P7Z`{n@nfU
z&)3_R8$u`W>4t7dZdh$TJ}pM%;NYHDyC{KCwwL4+oMeYxaD4p)X6^
z?)lqnVvuLf)!6g=-Dql%?z5(IhT8Lk@yt}*RoA)ale{t&IMm*`bvObNLqDYe
diff --git a/Media/show_normal.tga b/Media/show_normal.tga
deleted file mode 100644
index 90218c04d845d51a3843ee710e41c762c0a3408c..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@Pin$Y5XK)}xpduC35q2lsUg<-FNjDgfl{HhB3*XvNxX(v@Dg6c19*WcpYrKA
z1~Cu5%*@ODX67Y4=N=qa)m7b7h0)(?EoDRw7Tn}_SxR6u>-n*i5&Cgn__ZLp(xc~Z
zte9u{diW|7nC)&aR=Squ8!vAHS*9pP9m_w*U4e8y=b+^~Y<*L*z4F3zes?X!mE
zlXP<6Vd?8!yT|XR?*R5F^n_1qecm7NSUk(U_DG$dv#+0Qo`2`l&v5ace`JyS2T8h5
AL;wH)
diff --git a/Media/swap_down.tga b/Media/swap_down.tga
deleted file mode 100644
index 00474542584c466f9696fb98631ddbccf40afc33..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 1068
zcmb7@OA5j;5Qc**m+r0Fm(SWNy7B-O3!>nocoT2oy}XDA@B$-$bugVm+62B#CNm$&
zW|V3vULDm@Zkw~q{XsUchX`-8%dLQ;yIprgHsC$}FV2oPF7bT5OS!C9e1A&L54eS|
z-Eobh^n1aqaM(3P^h&=UM#Pw0)X*pW!EjDG=riTgcSkXSy0^Jc`rbGpQ0H@9`u=1=
zCfMU1=?Bv#nSig?EBDZ`dA8IwyhJDx9+1bT?@W`|QE;Mi{7@kEo&J6GMu9P4qvcrR4^FK+jT
zz}NlEwO@3b0^q= activeCount then
+ ns.Log(self.Name, "Hiding excess external bars", {
+ activeCount = activeCount,
+ pooledBars = #barPool,
+ })
+ end
+
for index = activeCount + 1, #barPool do
local bar = barPool[index]
if bar then
@@ -416,12 +557,33 @@ function ExternalBars:CreateFrame()
local frame = CreateFrame("Frame", "ECMExternalBars", UIParent)
frame:SetFrameStrata("MEDIUM")
frame:SetSize(1, 1)
+ ns.Log(self.Name, "Frame created", {
+ frameName = "ECMExternalBars",
+ frameWidth = frame:GetWidth(),
+ frameHeight = frame:GetHeight(),
+ })
return frame
end
function ExternalBars:HookViewer()
local viewer = getViewer()
- if not viewer or self._viewerHooked then
+ if not viewer then
+ if ns.IsDebugEnabled() then
+ ns.Log(self.Name, "HookViewer skipped", {
+ reason = "missing-viewer",
+ diagnostics = self:_GetDiagnostics(viewer),
+ })
+ end
+ return
+ end
+
+ if self._viewerHooked then
+ if ns.IsDebugEnabled() then
+ ns.Log(self.Name, "HookViewer skipped", {
+ reason = "already-hooked",
+ diagnostics = self:_GetDiagnostics(viewer),
+ })
+ end
return
end
@@ -451,7 +613,7 @@ function ExternalBars:HookViewer()
ns.Runtime.RequestLayout("ExternalBars:viewer:OnHide")
end)
- ns.Log(self.Name, "Hooked ExternalDefensivesFrame")
+ ns.Log(self.Name, "Hooked ExternalDefensivesFrame", self:_GetDiagnostics(viewer))
end
function ExternalBars:OnExternalAurasUpdated()
@@ -532,8 +694,10 @@ function ExternalBars:OnExternalAurasUpdated()
if debugEnabled then
ns.Log(self.Name, "OnExternalAurasUpdated", {
+ diagnostics = self:_GetDiagnostics(viewer, auraInfo),
viewerShown = viewer ~= nil and viewer:IsShown() or false,
viewerAlpha = viewer and viewer.GetAlpha and viewer:GetAlpha() or nil,
+ viewerHooked = self._viewerHooked == true,
hideOriginalIcons = self._originalIconsHidden == true,
runtimeOriginalIconsHidden = self._originalIconsHidden == true,
auraCount = activeAuraCount,
@@ -555,6 +719,7 @@ function ExternalBars:UpdateLayout(why)
ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
applied = false,
reason = "no-frame",
+ diagnostics = self:_GetDiagnostics(),
activeAuraCount = self._activeAuraCount or 0,
})
end
@@ -581,6 +746,7 @@ function ExternalBars:UpdateLayout(why)
ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
applied = false,
reason = "no-position",
+ diagnostics = self:_GetDiagnostics(),
activeAuraCount = self._activeAuraCount or 0,
})
end
@@ -599,6 +765,7 @@ function ExternalBars:UpdateLayout(why)
local activeAuraCount = self._activeAuraCount or 0
local auraStates = self._auraStates or {}
+ local barDiagnostics = debugEnabled and {} or nil
local ok, err = pcall(function()
for index = 1, activeAuraCount do
local auraState = auraStates[index]
@@ -606,6 +773,9 @@ function ExternalBars:UpdateLayout(why)
local bar = self:_ensureBar(index)
self:_ConfigureBar(bar, auraState, moduleConfig, globalConfig, styleConfig, spellColors)
activeBars[#activeBars + 1] = bar
+ if barDiagnostics then
+ barDiagnostics[#barDiagnostics + 1] = self:_GetBarDiagnostics(index, bar, auraState)
+ end
local textureFileID = FrameUtil.GetIconTextureFileID(bar)
local textureIsSecret = issecretvalue(textureFileID)
@@ -626,6 +796,14 @@ function ExternalBars:UpdateLayout(why)
if not ok then
self:_hideExcessBars(0)
self:_StopDurationTicker()
+ if debugEnabled then
+ ns.Log(self.Name, "UpdateLayout error", {
+ error = tostring(err),
+ diagnostics = self:_GetDiagnostics(),
+ activeAuraCount = activeAuraCount,
+ bars = barDiagnostics,
+ })
+ end
ns.DebugAssert(false, "Error styling external bars: " .. tostring(err))
return false
end
@@ -640,10 +818,13 @@ function ExternalBars:UpdateLayout(why)
local viewer = getViewer()
ns.Log(self.Name, "UpdateLayout (" .. (why or "") .. ")", {
+ diagnostics = self:_GetDiagnostics(viewer),
activeAuraCount = activeAuraCount,
barCount = barCount,
+ bars = barDiagnostics,
viewerShown = viewer ~= nil and viewer:IsShown() or false,
viewerAlpha = viewer and viewer.GetAlpha and viewer:GetAlpha() or nil,
+ viewerHooked = self._viewerHooked == true,
hideOriginalIcons = self._originalIconsHidden == true,
runtimeOriginalIconsHidden = self._originalIconsHidden == true,
editLocked = self._editLocked == true,
@@ -667,6 +848,7 @@ function ExternalBars:OnEnable()
self:EnsureFrame()
ns.Runtime.RegisterFrame(self)
+ ns.Log(self.Name, "OnEnable", self:_GetDiagnostics())
C_Timer.After(0.1, function()
if not self:IsEnabled() then
diff --git a/Tests/Modules/ExternalBars_spec.lua b/Tests/Modules/ExternalBars_spec.lua
index 69eb4cc7..da2efd36 100644
--- a/Tests/Modules/ExternalBars_spec.lua
+++ b/Tests/Modules/ExternalBars_spec.lua
@@ -628,6 +628,11 @@ describe("ExternalBars real source", function()
assert.are.equal(1, logEntry.payload.auraCount)
assert.is_true(logEntry.payload.viewerShown)
assert.are.equal("table", logEntry.payload.auraInfoType)
+ assert.is_table(logEntry.payload.diagnostics)
+ assert.is_true(logEntry.payload.diagnostics.viewerExists)
+ assert.is_true(logEntry.payload.diagnostics.viewerHooked)
+ assert.are.equal(1, logEntry.payload.diagnostics.auraInfoArrayCount)
+ assert.are.equal(1, logEntry.payload.diagnostics.auraFramesArrayCount)
assert.are.equal(1, #logEntry.payload.auras)
assert.same({
index = 1,
@@ -643,6 +648,60 @@ describe("ExternalBars real source", function()
}, logEntry.payload.auras[1])
end)
+ it("emits hook diagnostics when the Blizzard viewer is missing", function()
+ debugLoggingEnabled = true
+ _G.ExternalDefensivesFrame = nil
+
+ ExternalBars:HookViewer()
+
+ local logEntry = assert(findLogCall("HookViewer skipped"))
+ assert.are.equal("missing-viewer", logEntry.payload.reason)
+ assert.is_false(logEntry.payload.diagnostics.viewerExists)
+ assert.is_false(logEntry.payload.diagnostics.viewerHooked)
+ end)
+
+ it("emits rendered bar diagnostics during layout", function()
+ debugLoggingEnabled = true
+
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1.5,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("diagnostics"))
+
+ local logEntry = assert(findLogCall("UpdateLayout (diagnostics)"))
+ assert.is_table(logEntry.payload.diagnostics)
+ assert.is_true(logEntry.payload.diagnostics.frameCreated)
+ assert.is_true(logEntry.payload.diagnostics.viewerExists)
+ assert.are.equal(1, logEntry.payload.diagnostics.activeAuraCount)
+ assert.are.equal(1, #logEntry.payload.bars)
+ assert.same({
+ index = 1,
+ auraIndex = 1,
+ auraInstanceID = 11,
+ name = "Ironbark",
+ spellID = 102342,
+ texture = 5011,
+ durationIsSecret = false,
+ canShowDurationText = true,
+ hasRenderableDuration = true,
+ barExists = true,
+ barShown = true,
+ barWidth = 0,
+ barHeight = 18,
+ iconShown = true,
+ iconTexture = 5011,
+ cooldownSpellID = 102342,
+ }, logEntry.payload.bars[1])
+ end)
+
it("hides duration text but still configures cooldown and schedules the all-secret color retry path", function()
_G.issecretvalue = function()
return true
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index d4cee8b0..8e7b819b 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -721,51 +721,7 @@ describe("BuffBarsOptions", function()
assert.are.same(selectedColor, BuffSpellColors:GetDefaultColor())
assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({}, refreshCalls)
- end)
-
- it("does not refresh the spell colors page while applying repeated swatch color changes", function()
- local buffKey = SpellColors.MakeKey("Repeat Click", 111, 222, 333)
- local pickedColors = {
- { r = 0.2, g = 0.3, b = 0.4, a = 1 },
- { r = 0.7, g = 0.6, b = 0.5, a = 1 },
- }
- local pickerCalls, scheduledCalls = 0, 0
- local appliedSwatchColors = {}
- local rowFrame = {
- _swatch = {
- SetColorRGB = function(_, r, g, b)
- appliedSwatchColors[#appliedSwatchColors + 1] = { r = r, g = g, b = b }
- end,
- },
- }
-
- BuffSpellColors:SetColorByKey(buffKey, { r = 1, g = 1, b = 1, a = 1 })
-
- ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
- pickerCalls = pickerCalls + 1
- assert.is_false(hasOpacity)
- onChange(pickedColors[pickerCalls])
- end
- ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
- scheduledCalls = scheduledCalls + 1
- assert.are.equal("OptionsChanged", reason)
- end
-
- local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
- local item = getSpellColorCollectionItems(spellColorsSpec, "buffBars")[2]
-
- item.color.onClick(item, rowFrame)
- item.color.onClick(item, rowFrame)
-
- assert.are.equal(2, pickerCalls)
- assert.are.equal(2, scheduledCalls)
- assert.are.same(pickedColors[2], BuffSpellColors:GetColorByKey(buffKey))
- assert.are.same({
- { r = pickedColors[1].r, g = pickedColors[1].g, b = pickedColors[1].b },
- { r = pickedColors[2].r, g = pickedColors[2].g, b = pickedColors[2].b },
- }, appliedSwatchColors)
- assert.are.same({}, refreshCalls)
+ assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
end)
it("header actions disable reconcile and remove stale when every row is complete", function()
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index f6409336..6ee623b2 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -832,8 +832,8 @@ describe("ExtraIconsOptions settings page", function()
capturedPage = ns.ExtraIconsOptions.pages[1]
local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
registeredPage = page
- ns.ExtraIconsOptionsUtil.SetRegisteredPage(page)
- ns.ExtraIconsOptionsUtil.EnsureItemLoadFrame()
+ ns.ExtraIconsOptions.SetRegisteredPage(page)
+ ns.ExtraIconsOptions.EnsureItemLoadFrame()
registeredPage.Refresh = function()
refreshCalls[#refreshCalls + 1] = registeredPage._category
end
@@ -1197,6 +1197,7 @@ describe("ExtraIconsOptions settings page", function()
return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
end))
placeholder.onEnter(CreateFrame("Frame"))
+ assert.are.equal("ANCHOR_CURSOR", _G.GameTooltip._anchor)
assert.are.equal(ns.L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], _G.GameTooltip._lines[1])
local duplicateMove = assert(findItem("utility", function(item)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 7480e981..8a61e436 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -56,9 +56,7 @@ for _, racial in pairs(RACIAL_ABILITIES) do
end
local ExtraIconsOptions = ns.ExtraIconsOptions or {}
-local Util = ns.ExtraIconsOptionsUtil or {}
ns.ExtraIconsOptions = ExtraIconsOptions
-ns.ExtraIconsOptionsUtil = Util
ExtraIconsOptions._pendingItemLoads = ExtraIconsOptions._pendingItemLoads or {}
ExtraIconsOptions._draftStates = ExtraIconsOptions._draftStates or {}
@@ -71,9 +69,6 @@ for _, viewerKey in ipairs(VIEWER_ORDER) do
draftStates[viewerKey] = draftStates[viewerKey] or { kind = "spell", idText = "" }
end
-Util.IsDisabled = isDisabled
-Util.VIEWER_COLLECTION_HEIGHT = VIEWER_COLLECTION_HEIGHT
-
local function getProfile() return ns.Addon.db.profile end
local function getViewers() return getProfile().extraIcons.viewers end
@@ -219,7 +214,7 @@ local function showRowTooltip(owner, rowData)
end
local displayEntry = rowData.displayEntry
- GameTooltip:SetOwner(owner, "ANCHOR_RIGHT")
+ GameTooltip:SetOwner(owner, "ANCHOR_CURSOR")
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
@@ -553,9 +548,9 @@ function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
return rows
end
-function Util.SetRegisteredPage(page) registeredPage = page end
+function ExtraIconsOptions.SetRegisteredPage(page) registeredPage = page end
-function Util.EnsureItemLoadFrame()
+function ExtraIconsOptions.EnsureItemLoadFrame()
local itemLoadFrame = ExtraIconsOptions._itemLoadFrame
if not itemLoadFrame then
itemLoadFrame = CreateFrame("Frame")
@@ -767,7 +762,7 @@ local function buildModeInputTrailer(viewerKey)
}
end
-function Util.BuildSections()
+function ExtraIconsOptions.BuildSections()
local viewers = getViewers()
local sections = {}
for _, viewerKey in ipairs(VIEWER_ORDER) do
@@ -787,7 +782,7 @@ function Util.BuildSections()
return sections
end
-function Util.ResetToDefaults()
+function ExtraIconsOptions.ResetToDefaults()
local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
if not (defaults and defaults.extraIcons) then
return
@@ -809,7 +804,7 @@ ExtraIconsOptions.pages = {
key = "main",
onShow = function()
ns.Runtime.SetLayoutPreview(true)
- Util.EnsureItemLoadFrame()
+ ExtraIconsOptions.EnsureItemLoadFrame()
end,
onHide = function()
ns.Runtime.SetLayoutPreview(false)
@@ -824,10 +819,10 @@ ExtraIconsOptions.pages = {
end,
},
{
- id = "viewers", type = "sectionList", height = Util.VIEWER_COLLECTION_HEIGHT,
- disabled = Util.IsDisabled,
- sections = Util.BuildSections,
- onDefault = Util.ResetToDefaults,
+ id = "viewers", type = "sectionList", height = VIEWER_COLLECTION_HEIGHT,
+ disabled = isDisabled,
+ sections = ExtraIconsOptions.BuildSections,
+ onDefault = ExtraIconsOptions.ResetToDefaults,
},
},
},
diff --git a/UI/Options.lua b/UI/Options.lua
index 791bb796..c7751cde 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -123,9 +123,9 @@ function Options:OnInitialize()
sections = sections,
})
- if ns.ExtraIconsOptionsUtil then
- ns.ExtraIconsOptionsUtil.SetRegisteredPage(ns.Settings:GetPage("extraIcons", "main"))
- ns.ExtraIconsOptionsUtil.EnsureItemLoadFrame()
+ if ns.ExtraIconsOptions and ns.ExtraIconsOptions.SetRegisteredPage then
+ ns.ExtraIconsOptions.SetRegisteredPage(ns.Settings:GetPage("extraIcons", "main"))
+ ns.ExtraIconsOptions.EnsureItemLoadFrame()
end
if ns.PowerBarTickMarksOptions and ns.PowerBarTickMarksOptions.SetRegisteredPage then
ns.PowerBarTickMarksOptions.SetRegisteredPage(ns.Settings:GetPage("powerBar", "tickMarks"))
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
index f46f85cb..600b356a 100644
--- a/UI/SpellColorsPage.lua
+++ b/UI/SpellColorsPage.lua
@@ -210,13 +210,6 @@ local function refreshSpellColors(refreshPage)
refreshPage()
end
-local function applySpellColorChange(rowFrame, color)
- ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
- if rowFrame and rowFrame._swatch and rowFrame._swatch.SetColorRGB then
- rowFrame._swatch:SetColorRGB(color.r or 1, color.g or 1, color.b or 1)
- end
-end
-
---@param section table
---@return boolean
local function isSpellColorSectionDisabled(section)
@@ -326,8 +319,9 @@ local function removeStaleSpellColorSection(section)
end
---@param section table
+---@param refreshPage fun()
---@return table[]
-local function buildSpellColorItems(section)
+local function buildSpellColorItems(section, refreshPage)
local items = {}
local rows = getSectionSpellColorRows(section)
local spellColors = getSpellColors(section.scope)
@@ -351,14 +345,16 @@ local function buildSpellColorItems(section)
enabled = function()
return not isInteractionDisabled()
end,
- onClick = function(_, rowFrame)
+ onClick = function()
if isInteractionDisabled() then
return
end
ns.OptionUtil.OpenColorPicker(spellColors:GetDefaultColor(), false, function(color)
spellColors:SetDefaultColor(color)
- applySpellColorChange(rowFrame, color)
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
end)
end,
},
@@ -373,7 +369,7 @@ local function buildSpellColorItems(section)
enabled = function()
return not isInteractionDisabled()
end,
- onClick = function(_, rowFrame)
+ onClick = function()
if isInteractionDisabled() then
return
end
@@ -381,7 +377,9 @@ local function buildSpellColorItems(section)
local current = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor()
ns.OptionUtil.OpenColorPicker(current, false, function(color)
spellColors:SetColorByKey(row.key, color)
- applySpellColorChange(rowFrame, color)
+ refreshSpellColors(function()
+ doRefreshPage(refreshPage)
+ end)
end)
end,
},
From d88c6bf266db85237e3cfc255f4a875b496abd0d Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 25 Apr 2026 13:26:16 +1000
Subject: [PATCH 32/53] update docs
Co-authored-by: Copilot
---
AGENTS.md | 181 ++++++++++++++++++++++--------------------------
ARCHITECTURE.md | 30 ++------
2 files changed, 90 insertions(+), 121 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 03720ee8..9a005d33 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,129 +1,127 @@
+# Documentation Map
+
+Authoritative source for repo-wide agent rules. Topic-specific docs own their own surface — do not duplicate their content here.
+
+| Doc | Owns |
+|---|---|
+| [README.md](README.md) | User-facing overview, install, configuration |
+| [ARCHITECTURE.md](ARCHITECTURE.md) | Module boundaries, init chain, event flow, public APIs |
+| [Libs/LibSettingsBuilder/README.md](Libs/LibSettingsBuilder/README.md) | Settings builder API and schema |
+| [Libs/LibConsole/README.md](Libs/LibConsole/README.md) | Slash-command library |
+| [Libs/LibEvent/README.md](Libs/LibEvent/README.md) | Embeddable event system |
+| [Libs/LibLSMSettingsWidgets/README.md](Libs/LibLSMSettingsWidgets/README.md) | LSM picker templates |
+
+Keep `ARCHITECTURE.md` current for addon-level design changes; each library's README owns its own quick-start, API, and tests.
+
+---
+
# Validation
```sh
-# Addon tests
-busted Tests
-
-# Library tests
-busted --run libsettingsbuilder
+busted Tests # addon
+busted --run libsettingsbuilder # per-library suites
busted --run libconsole
busted --run libevent
busted --run liblsmsettingswidgets
-
-# Lint
luacheck . -q
```
-- Changes to `Modules/`, `Helpers/`, `UI/`, and `ECM*.lua` must pass `busted Tests` and `luacheck . -q`.
-- Library changes must also pass that library's dedicated test suite.
+- Changes to `Modules/`, `UI/`, or any root-level `*.lua` must pass `busted Tests` and `luacheck . -q`.
+- Changes under `Libs//` must additionally pass that library's suite.
---
# Core Rules
-
+All Lua files start with:
+
+```lua
-- Enhanced Cooldown Manager addon for World of Warcraft
-- Author: Argium
-- Licensed under the GNU General Public License v3.0
-
-
-- All Lua files must include the standard copyright header.
-- Keep [ARCHITECTURE.md](ARCHITECTURE.md) up to date.
-
+```
-## Lua / WoW Runtime
+## Architecture and Boundaries
-- Target WoW Lua 5.1; do not use post-5.1 features such as `goto`, labels, or `//`.
-- Do not add compatibility shims for built-ins already present in WoW. If a shim exists only for `busted`, document that.
-- Do not nil-check or wrap built-ins such as `issecretvalue`, `issecrettable`, or `canaccesstable`.
-- Prefer assertions for required parameters over guards and fallbacks. This prevents unexpected states from propogating deeper in to the system.
+- Prefer the simplest production code that satisfies current supported runtime requirements. No fallback paths, compatibility branches, or defensive adapters without a concrete supported environment that needs them.
+- Single source of truth for shared state and derived values: derive once, store once, read everywhere.
+- Loose coupling via events, hooks, callbacks, or messages.
+- No duplicated utilities or trivial passthrough wrappers — extend the canonical owner.
+- Don't extract a helper, wrapper, or abstraction unless it has an independently testable contract or 2+ callers.
+- No production-only indirection around fixed literals or stable signatures. Pass the value directly.
+- Prefer constant lookup tables over pure mapping functions for small fixed domains.
+- Remove dead code, stale fields, impossible branches, unused locale strings.
+- Clear critical state flags via `pcall` so one error can't wedge later work.
-## Config, Events, and State
+## State and Style
-- Mutable state belongs on the owning instance (`self._field`), not file-level locals. Prefix private fields and methods with `_`.
-- Do not use forward declarations. Alias shared modules once at file scope when reused.
+- Mutable state belongs on the owning instance (`self._field`), not file-level locals. Prefix private fields/methods with `_`.
+- No forward declarations. Alias shared modules once at file scope.
+- Prefer assertions for required parameters over guards and fallbacks.
+- Target WoW Lua 5.1 — no `goto`, labels, or `//`.
+- No compatibility shims for built-ins WoW already provides. Shims that exist only for `busted` must be documented as such.
## Performance
-- Never use `OnUpdate` or frame-rate tickers; prefer event-driven updates plus a single deferred timer when needed.
-- Reuse tables on hot paths with `wipe()`.
+- Never use `OnUpdate` or frame-rate tickers; use event-driven updates plus a single deferred timer when needed.
+- Reuse hot-path tables with `wipe()`. Avoid snapshot-copying callback lists.
- Cancel superseded timers before scheduling new deferred work.
-- Guard hot-path debug logging with `if ECM.IsDebugEnabled() then`.
-- Avoid snapshot-copying callback lists; use zero-allocation iteration that tolerates removal.
-- Periodic setup work must stop doing setup once all targets are handled.
-- Defer once out of restricted contexts; avoid stacked `C_Timer.After(0)` chains.
-
-## Architecture and Boundaries
-
-- Prefer loose coupling via events, hooks, callbacks, or messages.
-- Prefer the simplest production code that satisfies current supported runtime requirements. Do not add fallback paths, compatibility branches, or defensive adapters unless a concrete supported environment requires them.
-- Maintain a single source of truth for shared state and derived values: derive once, store once, read everywhere.
-- Do not duplicate utilities or add trivial passthrough wrappers; extend the canonical owner instead.
-- Before introducing a helper, wrapper, or abstraction, prove that it reduces real complexity for multiple callers or captures a distinct behavior that needs to be tested independently.
-- Do not extract single-use helpers unless they have a clear independently testable contract or 2+ callers.
-- Do not add production-only indirection around fixed literal values or stable API signatures. If a call always uses the same simple value, pass it directly unless a shared abstraction has a real runtime need.
-- Prefer constant lookup tables over pure mapping functions for small fixed domains.
-- Remove dead code, stale fields, impossible branches, and unused locale strings.
-- Clear critical state flags with `pcall` or equivalent so one error cannot wedge later work.
+- Periodic setup must stop once all targets are handled.
+- Defer once when leaving restricted contexts; don't stack `C_Timer.After(0)` chains.
+- Guard hot-path debug logs with `if ECM.IsDebugEnabled() then`.
## Code Density
-- Inline single-use local functions into their sole call site. Do not extract a helper just to give a three-line block a name.
-- When multiple table literals share identical structure (e.g. `{ normal = base .. X .. "_normal", pushed = base .. X .. "_down" }`), generate them with a constructor instead of writing each one out.
-- When the same two- or three-call sequence repeats across many callbacks (e.g. `scheduleUpdate(); refreshPage()`), extract one thin wrapper and call it everywhere.
-- Use `O(1)` set lookups (`SET[key]`) instead of linear scans (`for i, v in ipairs(list)`) when the list is fixed at load time.
-- Prefer compact single-line bodies for trivial functions: `local function f() return x end`.
-- When building repetitive declarative structures (action buttons, menu items), extract a factory that takes only the varying parts and returns the full structure.
-- Do not assign fields to `nil` just to "clear" them. Only assign fields that will be read later.
-- Closures that differ only in one value (e.g. direction = -1 vs +1) should call through a shared parameterised path, not duplicate the surrounding code.
-
-## Tests, Libraries, and Migrations
-
-- Be skeptical about changing tests to satisfy failures; the failure may be real.
-- If tests diverge from WoW or library runtime behavior, fix the stub or fixture to match production instead of adding fallback paths or compatibility helpers to live code.
-- Test load order must mirror TOC load order.
-- Stub the canonical function, not a wrapper or alias.
-- Test production code directly. Do not mirror, duplicate, or reimplement production logic in specs.
-- Reuse `Tests/TestHelpers.lua` before creating new shared test helpers.
-- Test files mirror source paths; library tests stay under `Libs//Tests/`.
-- `StaticPopup_Show` stubs must forward `(name, text1, text2, data)` and call `OnAccept(self, data)`.
-- Libraries must stay self-contained: no ECM internals; tests and docs live with the library; public API changes should be intentional and documented.
-- Do not use global hooks on Blizzard UI functions (like `Settings.CreateElementInitializer`) to simulate XML templates in pure Lua. Library frame templates must use `.xml` files to prevent widespread execution taint.
-- XML-defined virtual frame templates with `mixin="GlobalMixinName"` are inherently multi-addon safe via LibStub: the Lua runs once (defining the global mixin tables), and WoW resolves mixin names lazily at `CreateFrame` time. Do not replace XML templates with Lua-based mixin injection to "support multiple addons" — it breaks the initialization pipeline and causes taint.
+- Inline single-use locals into their sole call site.
+- Generate repeated structural literals from a constructor; extract a thin wrapper for repeated 2–3 call sequences.
+- `O(1)` set lookups over linear scans for fixed load-time lists.
+- Compact single-line bodies for trivial functions.
+- Don't assign fields to `nil` to "clear" them — only assign fields that will be read later.
+- Closures differing only in one value should share a parameterised path.
+
+## Tests
+
+- Be skeptical when changing tests to satisfy failures — the failure may be real.
+- Test load order mirrors TOC load order. Test files mirror source paths; library tests live under `Libs//Tests/`.
+- Test production code directly. Don't mirror or reimplement production logic in specs.
+- Stub the canonical function, not a wrapper or alias. If a stub diverges from real behavior, fix the stub — don't add fallbacks to live code.
+- Reuse `Tests/TestHelpers.lua` before creating new shared helpers.
+- `StaticPopup_Show` stubs forward `(name, text1, text2, data)` and call `OnAccept(self, data)`.
- Shared confirm dialogs use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`.
-- Migrations in `Helpers/Migration.lua` are frozen snapshots and must not depend on live production code.
+
+## Libraries and Migrations
+
+- Libraries stay self-contained: no ECM internals; tests and docs live with the library; public API changes are intentional and documented.
+- Frame templates must be defined in `.xml`, not via Lua hooks on Blizzard functions like `Settings.CreateElementInitializer`. XML virtual templates with `mixin="GlobalMixinName"` are inherently multi-addon safe via LibStub.
+- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code.
---
# Review Heuristics
-- Optimize for simple, explicit, maintainable code.
-- Watch for unused variables, redundant guards or assignments, duplication, tight coupling, needless complexity, missing coverage, and avoidable allocations.
+Optimize for simple, explicit, maintainable code. Watch for unused variables, redundant guards, duplication, tight coupling, needless complexity, missing coverage, and avoidable allocations.
---
# Secret Values
-- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values.
-- Only nil-check them or pass them to built-ins or APIs that accept secrets.
-- Do not do arithmetic, comparisons, boolean tests, length, indexing, assignment, iteration, or use them as table keys.
-- Storing secret values in locals, upvalues, or table values is allowed; concatenation and string formatting with string or number secrets is allowed.
-- Secret tables may always yield secret values or be fully inaccessible; `canaccesstable(table)` only tells you whether access would be allowed.
+Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values.
+
+- Only nil-check them or pass them to built-ins/APIs that accept secrets.
+- No arithmetic, comparisons, boolean tests, length, indexing, assignment, iteration, or use as table keys.
+- Storing in locals/upvalues/table values is fine; concatenation and string formatting with string/number secrets is fine.
+- Secret tables may yield secret values or be fully inaccessible; `canaccesstable(table)` only reports access, not contents.
+- Don't nil-check or wrap built-ins like `issecretvalue`, `issecrettable`, `canaccesstable`.
---
# Deprecated Blizzard APIs (12.0.5)
-Do not use any of the functions, constants, or mixins listed below. They are deprecated shims provided by Blizzard for backward compatibility and may be removed in a future patch. Use the modern replacement shown in the Blizzard source (typically a `C_*` namespace method or mixin method) instead.
-
-Source: `Blizzard_Deprecated*` folders in https://github.com/Gethe/wow-ui-source/tree/12.0.5/Interface/AddOns
+Do not use the functions, constants, or mixins listed below — they are backward-compat shims and may be removed. Use the modern replacement (typically a `C_*` namespace method or mixin method) shown in Blizzard source: https://github.com/Gethe/wow-ui-source/tree/12.0.5/Interface/AddOns (`Blizzard_Deprecated*` folders).
## Blizzard_Deprecated
-- `GetBattlefieldScore`
-- `GetBattlefieldStatData`
-- `UnitIsSpellTarget`
-- `C_SpellBook.GetSpellBookItemLossOfControlCooldown`
+`GetBattlefieldScore`, `GetBattlefieldStatData`, `UnitIsSpellTarget`, `C_SpellBook.GetSpellBookItemLossOfControlCooldown`
## Blizzard_DeprecatedChatInfo
@@ -135,43 +133,32 @@ ChatFrameMixin aliases: `ChatFrame_AddMessage`, `ChatFrame_AddMessageGroup`, `Ch
ChatFrameEditBoxMixin aliases: `ChatEdit_AddHistory`, `ChatEdit_ClearChat`, `ChatEdit_DoesCurrentChannelTargetMatch`, `ChatEdit_ExtractChannel`, `ChatEdit_ExtractTellTarget`, `ChatEdit_GetChannelTarget`, `ChatEdit_HandleChatType`, `ChatEdit_ParseText`, `ChatEdit_ResetChatType`, `ChatEdit_ResetChatTypeToSticky`, `ChatEdit_SendText`, `ChatEdit_SetDeactivated`, `ChatEdit_UpdateHeader`
-API functions: `SendChatMessage`, `DoEmote`, `CancelEmote`
+API: `SendChatMessage`, `DoEmote`, `CancelEmote`
## Blizzard_DeprecatedInstanceEncounter
-- `IsEncounterInProgress`, `IsEncounterSuppressingRelease`, `IsEncounterLimitingResurrections`
+`IsEncounterInProgress`, `IsEncounterSuppressingRelease`, `IsEncounterLimitingResurrections`
## Blizzard_DeprecatedItemScript
-- `GetItemQualityColor`, `GetItemInfoInstant`, `GetItemSetInfo`, `GetItemChildInfo`, `DoesItemContainSpec`, `GetItemGem`, `GetItemCreationContext`, `GetItemIcon`, `GetItemFamily`, `GetItemSpell`
-- `IsArtifactPowerItem`, `IsCurrentItem`, `IsUsableItem`, `IsHelpfulItem`, `IsHarmfulItem`, `IsConsumableItem`, `IsEquippableItem`, `IsEquippedItem`, `IsEquippedItemType`
-- `ItemHasRange`, `IsItemInRange`, `GetItemClassInfo`, `GetItemInventorySlotInfo`
-- `BindEnchant`, `ActionBindsItem`, `ReplaceEnchant`, `ReplaceTradeEnchant`, `ConfirmBindOnUse`, `ConfirmOnUse`, `ConfirmNoRefundOnUse`
-- `DropItemOnUnit`, `EndBoundTradeable`, `EndRefund`
-- `GetItemInfo`, `GetDetailedItemLevelInfo`, `GetItemSpecInfo`, `GetItemUniqueness`, `GetItemCount`, `PickupItem`, `GetItemSubClassInfo`
-- `UseItemByName`, `EquipItemByName`, `ReplaceTradeskillEnchant`, `GetItemCooldown`
-- `IsCorruptedItem`, `IsCosmeticItem`, `IsDressableItem`
+`GetItemQualityColor`, `GetItemInfoInstant`, `GetItemSetInfo`, `GetItemChildInfo`, `DoesItemContainSpec`, `GetItemGem`, `GetItemCreationContext`, `GetItemIcon`, `GetItemFamily`, `GetItemSpell`, `IsArtifactPowerItem`, `IsCurrentItem`, `IsUsableItem`, `IsHelpfulItem`, `IsHarmfulItem`, `IsConsumableItem`, `IsEquippableItem`, `IsEquippedItem`, `IsEquippedItemType`, `ItemHasRange`, `IsItemInRange`, `GetItemClassInfo`, `GetItemInventorySlotInfo`, `BindEnchant`, `ActionBindsItem`, `ReplaceEnchant`, `ReplaceTradeEnchant`, `ConfirmBindOnUse`, `ConfirmOnUse`, `ConfirmNoRefundOnUse`, `DropItemOnUnit`, `EndBoundTradeable`, `EndRefund`, `GetItemInfo`, `GetDetailedItemLevelInfo`, `GetItemSpecInfo`, `GetItemUniqueness`, `GetItemCount`, `PickupItem`, `GetItemSubClassInfo`, `UseItemByName`, `EquipItemByName`, `ReplaceTradeskillEnchant`, `GetItemCooldown`, `IsCorruptedItem`, `IsCosmeticItem`, `IsDressableItem`
## Blizzard_DeprecatedPvpScript
-- `IsSubZonePVPPOI`, `GetZonePVPInfo`, `TogglePVP`, `SetPVP`
+`IsSubZonePVPPOI`, `GetZonePVPInfo`, `TogglePVP`, `SetPVP`
## Blizzard_DeprecatedSpecialization
Standard: `GetNumSpecializationsForClassID`, `GetSpecializationInfo`, `GetSpecialization`, `GetActiveSpecGroup`, `GetSpecializationMasterySpells`, `GetTalentInfo`
-Classic variants: `SetActiveTalentGroup`, `GetTalentTabInfo`, `GetPrimaryTalentTree`, `GetActiveTalentGroup`, `GetTalentTreeMasterySpells`
+Classic: `SetActiveTalentGroup`, `GetTalentTabInfo`, `GetPrimaryTalentTree`, `GetActiveTalentGroup`, `GetTalentTreeMasterySpells`
Constants: `MAX_TALENT_TIERS`, `NUM_TALENT_COLUMNS`
## Blizzard_DeprecatedSpellBook
-Constant: `HUNTER_DISMISS_PET`
-
-Functions: `IsPlayerSpell`, `IsSpellKnown`, `IsSpellKnownOrOverridesKnown`, `FindFlyoutSlotBySpellID`, `FindSpellOverrideByID`, `FindBaseSpellByID`
+`HUNTER_DISMISS_PET`, `IsPlayerSpell`, `IsSpellKnown`, `IsSpellKnownOrOverridesKnown`, `FindFlyoutSlotBySpellID`, `FindSpellOverrideByID`, `FindBaseSpellByID`
## Blizzard_DeprecatedSpellScript
-- `TargetSpellReplacesBonusTree`, `GetMaxSpellStartRecoveryOffset`, `GetSpellQueueWindow`, `GetSchoolString`
-- `SpellIsPriorityAura`, `SpellIsSelfBuff`, `SpellGetVisibilityInfo`
-- `C_Spell.GetSpellLossOfControlCooldown`
+`TargetSpellReplacesBonusTree`, `GetMaxSpellStartRecoveryOffset`, `GetSpellQueueWindow`, `GetSchoolString`, `SpellIsPriorityAura`, `SpellIsSelfBuff`, `SpellGetVisibilityInfo`, `C_Spell.GetSpellLossOfControlCooldown`
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index dbdb0ac9..884750aa 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -197,36 +197,18 @@ LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced
### Options UI
-Setting changes flow through LibSettingsBuilder's `onChange` → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`.
-The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, which guarantees `Core.lua`, the primitive helper modules, standard control modules, composite control modules, and `Utility.lua` initialize in order before options pages register.
+Setting changes flow through LibSettingsBuilder's `onChanged` callback → `Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")`. The embedded library is loaded through `Libs/LibSettingsBuilder/embed.xml`, which guarantees `Core.lua`, the primitive helper modules, standard control modules, composite control modules, and `Utility.lua` initialize in order before options pages register. See [`Libs/LibSettingsBuilder/README.md`](Libs/LibSettingsBuilder/README.md) for the library's public surface, declarative schema, and canonical row types.
-Options pages now use LibSettingsBuilder as a single declarative registration tree:
+ECM uses LibSettingsBuilder as a single declarative registration tree:
-- `SB.GetRoot(L["ADDON_NAME"])` returns the singleton root handle,
+- `UI/Options.lua` owns the root assembly and calls `LSB.New({ name = ..., page = ns.AboutPage, sections = { ... } })` once,
- each options page has a dedicated `UI/*Options.lua` or `UI/*Page.lua` owner (`UI/AboutOptions.lua`, `UI/AdvancedOptions.lua`, `UI/SpellColorsPage.lua`, etc.) that exports plain section/page spec tables instead of registering itself,
-- `UI/Options.lua` owns only the root SettingsBuilder assembly and calls `root:Register({ page = ns.AboutPage, sections = { ... } })` once,
-- `root:Register(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
+- `LSB.New(...)` materializes the tree into Blizzard Settings (flattening single-page sections by default and nesting multi-page sections automatically),
- dynamic pages keep a registered page handle through `onRegistered(page)` and refresh via `page:Refresh()` when async or transient state changes.
-`UI/SpellColorsPage.lua` now owns the shared Spell Colors subcategory. `BuffBarsOptions` registers the page once, and both `BuffBars` and `ExternalBars` register scoped sections into it, so the two modules share one editor without sharing saved color pools.
+`UI/SpellColorsPage.lua` owns the shared Spell Colors subcategory. `BuffBarsOptions` registers the page once, and both `BuffBars` and `ExternalBars` register scoped sections into it, so the two modules share one editor without sharing saved color pools.
-LibSettingsBuilder v2 Phase 1 also freezes the intended replacement surface without switching ECM over to it yet:
-
-- target factory: `LSB.New(config)`
-- target runtime lookups: `lsb:GetSection(...)`, `lsb:GetRootPage()`, `lsb:GetPage(...)`, `lsb:HasCategory(...)`
-- target page handle API: `page:GetId()`, `page:Refresh()`
-- deprecated compatibility namespace: `LSBDeprecated`
-
-Phase 2 then makes raw declarative row tables the canonical registration schema and removes builder-level row helper constructors from the public `lsb` surface. ECM continues to register through plain row tables.
-
-Deprecated non-declarative page-construction APIs were removed from the builder surface. ECM settings pages are registered through the root tree only.
-
-Pages still use the same canonical row types:
-
-- standard persisted controls (`checkbox`, `slider`, `dropdown`, `color`, `input`) bind through path mode or handler mode,
-- layout rows (`header`, `subheader`, `info`, `button`, `pageActions`, `canvas`) handle structure and copy,
-- dynamic editors use `list` and `sectionList` rows with library-owned rendering,
-- `canvas` rows stay on the existing lifecycle path so page switches do not lose or misplace embedded content.
+ECM only consumes the documented public surface (`LSB.New`, `lsb:GetSection`, `lsb:GetRootPage`, `lsb:GetPage`, `lsb:HasCategory`, `page:GetId`, `page:Refresh`) and registers pages through raw declarative row tables — no builder-level helper constructors and no deprecated transition namespaces.
### Watchdog Ticker
From 52fce64b1a6e9c716ff13ce575aa51d384e5a06d Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 25 Apr 2026 13:26:57 +1000
Subject: [PATCH 33/53] update docs
---
ARCHITECTURE.md | 237 +++++++++------------
Libs/LibSettingsBuilder/README.md | 66 +++---
docs/BuffBars.md | 297 ++++++++++++++++++++++++++
docs/ExternalBars.md | 278 +++++++++++++++++++++++++
docs/ExtraIcons.md | 313 ++++++++++++++++++++++++++++
docs/PowerBar.md | 261 +++++++++++++++++++++++
docs/ResourceBar.md | 334 ++++++++++++++++++++++++++++++
docs/RuneBar.md | 292 ++++++++++++++++++++++++++
8 files changed, 1902 insertions(+), 176 deletions(-)
create mode 100644 docs/BuffBars.md
create mode 100644 docs/ExternalBars.md
create mode 100644 docs/ExtraIcons.md
create mode 100644 docs/PowerBar.md
create mode 100644 docs/ResourceBar.md
create mode 100644 docs/RuneBar.md
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 884750aa..710dac45 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -3,6 +3,57 @@
EnhancedCooldownManager is an event-driven WoW addon built on AceAddon-3.0 / AceDB-3.0.
`Runtime.lua` is the central dispatcher: it registers WoW events, manages layout coalescing, lays out `ExtraIcons` first when it widens the main viewer, and then iterates the chained bar modules. `PowerBar`, `ResourceBar`, and `RuneBar` use `BarMixin.AddBarMixin()`. `BuffBars`, `ExternalBars`, and `ExtraIcons` use `BarMixin.AddFrameMixin()` and manage their own child content.
+## Modules
+
+Each module owns its own reference doc with a summary table, actor diagram, component-interaction diagram, and data model:
+
+| Module | Doc | Mixin |
+|---|---|---|
+| PowerBar | [docs/PowerBar.md](docs/PowerBar.md) | `AddBarMixin` |
+| ResourceBar | [docs/ResourceBar.md](docs/ResourceBar.md) | `AddBarMixin` |
+| RuneBar | [docs/RuneBar.md](docs/RuneBar.md) | `AddBarMixin` |
+| BuffBars | [docs/BuffBars.md](docs/BuffBars.md) | `AddFrameMixin` |
+| ExternalBars | [docs/ExternalBars.md](docs/ExternalBars.md) | `AddFrameMixin` |
+| ExtraIcons | [docs/ExtraIcons.md](docs/ExtraIcons.md) | `AddFrameMixin` |
+
+## Startup and the generic event pulse
+
+Cross-cutting view: addon startup, then the generic event → Runtime → module layout pulse. Per-scenario flows (profile change, Edit Mode, options, import/export, per-module data events) live in the module reference docs.
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game (WoW client)
+ participant ACE as ACE (AceAddon / AceDB)
+ participant ECM as ECM (addon root)
+ participant Runtime as Runtime
+ participant Module as Module(s)
+
+ rect rgb(26,26,46)
+ note over Game,Module: Addon startup
+ Game->>ACE: ADDON_LOADED
+ ACE->>ECM: OnInitialize()
+ ECM->>ECM: Migration.PrepareDatabase() / Run(profile)
+ ECM->>ACE: AceDB-3.0:New(defaults)
+ ACE->>Module: OnInitialize() → BarMixin.Add*Mixin
+ Game->>ACE: PLAYER_LOGIN
+ ACE->>ECM: OnEnable()
+ ECM->>Runtime: Runtime.Enable(addon)
+ Runtime->>Module: EnableModule / EnsureFrame / RegisterFrame
+ Module->>Game: RegisterEvent(module-specific events)
+ Runtime->>Game: RegisterEvent(layout events) + watchdog ticker
+ Runtime->>Module: UpdateLayout("ModuleInit")
+ end
+
+ rect rgb(26,46,30)
+ note over Game,Module: Generic event pulse
+ Game->>Runtime: layout event fires
+ Runtime->>Runtime: handleLayoutEvent → RequestLayout / ScheduleLayoutUpdate
+ Runtime->>Runtime: executeLayout → updateFadeAndHiddenStates
+ Runtime->>Module: SetHidden / SetAlpha / UpdateLayout(reason)
+ end
+```
+
## Initialization Chain
Six phases from TOC load through the first rendered frame.
@@ -187,13 +238,7 @@ flowchart TD
## Secondary Flows
-### Profile Change
-
-When a user switches, copies, or resets a profile, AceDB fires a callback → `ECM:OnProfileChangedHandler()` → re-runs migration → `Runtime.Enable()` re-enables/disables modules per new config → schedules a full layout with reason `"ProfileChanged"`. BuffBars and ExternalBars clear their scoped SpellColors discovered-key caches on this reason.
-
-### Edit Mode
-
-LibEditMode detects WoW's Edit Mode enter/exit. On enter, all modules are forced visible (alpha 1, not hidden). Dragging or resizing calls `UpdateLayoutImmediately()` for instant feedback. On exit, normal fade/hidden rules re-apply.
+Profile change, Edit Mode, and per-module reactions are documented in each [module reference doc](#modules). The flows below are cross-cutting concerns that don't belong to any single module.
### Options UI
@@ -214,59 +259,12 @@ ECM only consumes the documented public surface (`LSB.New`, `lsb:GetSection`, `l
A 0.5s `C_Timer.NewTicker` handles deferred Blizzard frame hooking (stops retrying once setup completes), enforces hidden/alpha state against Blizzard re-shows, and syncs module alpha.
-### External Bars / `ExternalDefensivesFrame`
-
-`ExternalBars` is hook-driven rather than event-driven. It mirrors Blizzard's `ExternalDefensivesFrame` instead of scanning auras itself:
-
-1. `OnEnable()` defers `HookViewer()` by `C_Timer.After(0.1)` so the Blizzard frame exists before hooks are attached.
-2. `HookViewer()` post-hooks `ExternalDefensivesFrame:UpdateAuras()` and listens to the frame's `OnShow` / `OnHide` transitions.
-3. `OnExternalAurasUpdated()` copies `viewer.auraInfo[]` into `_auraStates[]`, enriches accessible spell metadata through `C_UnitAuras.GetAuraDataByAuraInstanceID()`, and requests a layout pass.
-4. `UpdateLayout()` maps `_auraStates[]` to pooled child bars, styles each row through `BarStyle.StyleChildBar(...)`, and routes spell-color discovery / lookup through the `ns.SpellColors.Get("externalBars")` store.
-5. Bar fill is rendered by a `Cooldown` overlay via `SetCooldownDuration(duration, timeMod)`. Lua only formats duration text when expiration data is safe to inspect directly.
-6. `hideOriginalIcons` uses `ExternalDefensivesFrame:SetAlpha(0)` plus `EnableMouse(false)` rather than `Hide()`, so Blizzard keeps driving `UpdateAuras()`.
-
```mermaid
flowchart TD
- subgraph PROFILE["Profile Change Flow"]
- USER_SWITCH["User switches/copies/resets profile"]
- ACE_CB["AceDB callback fires:
OnProfileChanged / OnProfileCopied / OnProfileReset"]
- PROF_HANDLER["ECM:OnProfileChangedHandler()"]
- MIG["Migration.Run(new profile)"]
- RT_EN2["Runtime.Enable(addon)
→ Re-enable/disable modules per new config"]
- SCHED_PC["ScheduleLayoutUpdate(0, 'ProfileChanged')"]
- SPELL_CLEAR["BuffBars / ExternalBars:UpdateLayout('ProfileChanged')
→ SpellColors.Get(scope):ClearDiscoveredKeys()"]
-
- USER_SWITCH --> ACE_CB --> PROF_HANDLER --> MIG --> RT_EN2 --> SCHED_PC --> SPELL_CLEAR
- end
-
- subgraph EDITMODE["Edit Mode Flow"]
- EM_ENTER["User enters WoW Edit Mode"]
- EM_DETECT["LibEditMode callback → 'enter'"]
- EM_LAYOUT["ScheduleLayoutUpdate(0, 'EditModeEnter')"]
- EM_FORCE["updateFadeAndHiddenStates()
→ hidden=false, alpha=1 (always visible)"]
-
- EM_DRAG["User drags module frame"]
- EM_SAVE["onPositionChanged callback
→ EditMode.SavePosition(config, ...)"]
- EM_IMMED["UpdateLayoutImmediately('EditModeDrag')"]
-
- EM_SLIDER["User adjusts width/height slider"]
- EM_WRITE["Direct config write: cfg.width = value"]
- EM_IMMED2["UpdateLayoutImmediately('EditModeWidth')"]
-
- EM_EXIT["User exits Edit Mode"]
- EM_EXIT_LAY["ScheduleLayoutUpdate(0, 'EditModeExit')
→ Re-apply fade/hidden per config"]
-
- EM_ENTER --> EM_DETECT --> EM_LAYOUT --> EM_FORCE
- EM_DRAG --> EM_SAVE --> EM_IMMED
- EM_SLIDER --> EM_WRITE --> EM_IMMED2
- EM_EXIT --> EM_EXIT_LAY
- end
-
subgraph OPTIONS["Options UI Flow"]
OPT_CHANGE["User toggles setting in Options UI"]
LSB_CB["LibSettingsBuilder onChange callback"]
OPT_SCHED["Runtime.ScheduleLayoutUpdate(0, 'OptionsChanged')"]
-
OPT_CHANGE --> LSB_CB --> OPT_SCHED
end
@@ -276,49 +274,49 @@ flowchart TD
WD_HOOK["hookBlizzardFrames()
hookCooldownViewerSettings()"]
WD_ENFORCE["enforceBlizzardFrameState()
→ Correct Blizzard re-shows/alpha"]
WD_ALPHA["Sync module alpha
→ LazySetAlpha per module"]
-
WD_TICK --> WD_SETUP
WD_SETUP -->|no| WD_HOOK --> WD_ENFORCE
WD_SETUP -->|yes| WD_ENFORCE --> WD_ALPHA
end
-
- style PROFILE fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
- style EDITMODE fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
- style OPTIONS fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
- style WATCHDOG fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0
```
## Event Reference
-Runtime registers the shared layout events; modules register their own data-driven events in `OnEnable`. Events with multiple registrants are intentional — Runtime handles visibility/positioning while the module handles its own data refresh.
-
-`ExternalBars` is intentionally absent from this table: it mirrors `ExternalDefensivesFrame` through hooks rather than registering its own aura event stream.
-
-| Event | Registrant(s) | Purpose |
-|-------|---------------|---------|
-| CVAR_UPDATE | Runtime | Schedules layout when `cooldownViewerEnabled` changes |
-| PLAYER_ENTERING_WORLD | Runtime, BuffBars, ExtraIcons | Runtime: full layout; BuffBars: refresh zone buffs; ExtraIcons: full refresh |
-| PLAYER_MOUNT_DISPLAY_CHANGED | Runtime | Immediate layout for mounted-state visibility |
-| PLAYER_REGEN_DISABLED | Runtime | Immediate layout; sets `_inCombat` flag |
-| PLAYER_REGEN_ENABLED | Runtime | Delayed layout (combat-end delay); clears `_inCombat` |
-| PLAYER_SPECIALIZATION_CHANGED | Runtime | Immediate layout for spec-dependent module visibility |
-| PLAYER_TARGET_CHANGED | Runtime | Immediate layout for target-frame positioning |
-| PLAYER_UPDATE_RESTING | Runtime | Immediate layout for resting-state visibility |
-| UNIT_ENTERED_VEHICLE | Runtime | Immediate layout to hide bars in vehicle |
-| UNIT_EXITED_VEHICLE | Runtime | Immediate layout to restore bars after vehicle |
-| UPDATE_SHAPESHIFT_FORM | Runtime | Immediate layout for form/stance changes |
-| VEHICLE_UPDATE | Runtime | Immediate layout for vehicle seat changes |
-| ZONE_CHANGED | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh zone-specific buffs |
-| ZONE_CHANGED_INDOORS | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh buff data |
-| ZONE_CHANGED_NEW_AREA | Runtime, BuffBars | Runtime: delayed layout; BuffBars: refresh area-specific buffs |
-| BAG_UPDATE_COOLDOWN | ExtraIcons | Throttled cooldown-state refresh |
-| BAG_UPDATE_DELAYED | ExtraIcons | Layout update after bag contents finalize |
-| PLAYER_EQUIPMENT_CHANGED | ExtraIcons | Refresh tracked equipment slot cooldowns on gear swap |
-| SPELLS_CHANGED | ExtraIcons | Layout update when known spells change (talent/level) |
-| SPELL_UPDATE_COOLDOWN | ExtraIcons | Throttled spell cooldown-state refresh |
+`ExternalBars` is hook-driven (mirrors `ExternalDefensivesFrame`) and does not register events directly.
+
+### Runtime layout events
+
+Registered in `Runtime.Enable` and dispatched through `handleLayoutEvent`. Modules can also register the same event for their own data refresh; that's noted under the per-module column.
+
+| Event | Co-listeners | Behavior |
+|---|---|---|
+| CVAR_UPDATE | — | Schedules layout when `cooldownViewerEnabled` changes |
+| PLAYER_ENTERING_WORLD | BuffBars, ExtraIcons | Full layout (delay 0.4s) |
+| PLAYER_MOUNT_DISPLAY_CHANGED | — | Immediate layout (mounted visibility) |
+| PLAYER_REGEN_DISABLED | — | Immediate layout; sets `_inCombat` |
+| PLAYER_REGEN_ENABLED | — | Delayed layout (combat-end delay); clears `_inCombat` |
+| PLAYER_SPECIALIZATION_CHANGED | — | Immediate layout (spec-dependent visibility) |
+| PLAYER_TARGET_CHANGED | — | Immediate layout (target-frame positioning) |
+| PLAYER_UPDATE_RESTING | — | Immediate layout (resting visibility) |
+| UNIT_ENTERED_VEHICLE / UNIT_EXITED_VEHICLE / VEHICLE_UPDATE | — | Immediate layout |
+| UPDATE_SHAPESHIFT_FORM | — | Immediate layout (form/stance changes) |
+| ZONE_CHANGED / ZONE_CHANGED_INDOORS / ZONE_CHANGED_NEW_AREA | BuffBars | Delayed layout (0.1s) |
+
+### Module data events
+
+Registered by each module in its own `OnEnable`. See the [module reference doc](#modules) for handler details.
+
+| Event | Module | Purpose |
+|---|---|---|
+| UNIT_POWER_UPDATE | PowerBar, ResourceBar | Power-bar value update |
+| UNIT_AURA | ResourceBar | Aura-driven resource refresh |
| RUNE_POWER_UPDATE | RuneBar | Start rune animation ticker; request layout |
-| UNIT_AURA | ResourceBar | Layout update when player auras change |
-| UNIT_POWER_UPDATE | PowerBar, ResourceBar | PowerBar: primary power bar update; ResourceBar: resource tracking |
+| BAG_UPDATE_COOLDOWN | ExtraIcons | Throttled cooldown-state refresh |
+| BAG_UPDATE_DELAYED | ExtraIcons | Layout after bag contents finalize |
+| PLAYER_EQUIPMENT_CHANGED | ExtraIcons | Refresh tracked equipment-slot cooldowns |
+| SPELLS_CHANGED | ExtraIcons | Layout when known spells change |
+| SPELL_UPDATE_COOLDOWN | ExtraIcons | Throttled spell cooldown refresh |
+| ZONE_CHANGED* / PLAYER_ENTERING_WORLD | BuffBars | Refresh zone-specific buffs |
## Public Interfaces
@@ -393,63 +391,16 @@ Two mixins applied in `OnInitialize`. `FrameProto` provides positioning, visibil
| `StyleBarAnchors(frame, bar, iconFrame, config)` | Apply the shared text / icon anchor layout |
| `StyleChildBar(module, frame, config, globalConfig, spellColors?)` | Run the complete shared BuffBars / ExternalBars child-bar styling pass |
-### ExternalBars (`Modules/ExternalBars.lua`)
-
-Renders Blizzard external defensive auras as ECM-owned bar rows. `ExternalBars` inherits `FrameProto`, owns a pooled set of child bars, and shares the BuffBars row styling helpers from `BarStyle`.
-
-- **Authoritative source:** `ExternalDefensivesFrame.auraInfo[]` populated by Blizzard `UpdateAuras()`.
-- **Viewer hook:** `HookViewer()` post-hooks `UpdateAuras()` and `OnShow` / `OnHide`.
-- **Internal state:** `OnExternalAurasUpdated()` copies current aura entries into `_auraStates[]` keyed by Blizzard's aura-array index, not by `auraInstanceID`.
-- **Rendering:** `UpdateLayout()` sizes the container, configures pooled child bars, and uses a `Cooldown` overlay per row for the draining fill.
-- **Color scope:** spell-color lookup and discovery run through `ns.SpellColors.Get("externalBars")`, so the shared Spell Colors page can manage it independently from BuffBars while the runtime avoids passing scope strings through every call.
-- **Original icon suppression:** `hideOriginalIcons` drives `SetAlpha(0)` / `EnableMouse(false)` on `ExternalDefensivesFrame`; it never calls `Hide()`.
-
-### ExtraIcons (`Modules/ExtraIcons.lua`)
-
-Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Uses a dual-viewer architecture with a stack-aware resolver.
-
-**Viewer Registry:** Maps abstract viewer keys to Blizzard frame globals. Current keys: `"utility"` → `UtilityCooldownViewer`, `"main"` → `EssentialCooldownViewer`. Each viewer has its own container frame, on-demand icon pool, and hook set. The main viewer's expanded footprint also drives a shared midpoint offset for the two-viewer layout; utility applies that pair offset first, then layers its own local centering so moving icons between viewers preserves the combined layout.
-
-**Entry Kinds and Resolution:**
-
-| Kind | Config Fields | Resolution | Cooldown Source |
-|------|--------------|------------|-----------------|
-| `equipSlot` | `slotId` | `GetInventoryItemID` + `C_Item.GetItemSpell` on-use check | `GetInventoryItemCooldown` |
-| `item` | `ids[]` (priority stack) | First with `C_Item.GetItemCount > 0` | `C_Item.GetItemCooldown` |
-| `spell` | `ids[]` (priority stack) | First known via `C_SpellBook.IsSpellKnown`, then `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
-
-Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Built-in entries may also persist `disabled = true`, which keeps them in the settings list but skips them during runtime resolution. Custom and racial entries store fields directly in saved config.
-
-**Config Structure (`profile.extraIcons`):**
-
-```lua
-{
- enabled = true,
- viewers = {
- utility = { -- ordered array
- { stackKey = "trinket1" }, -- resolved from BUILTIN_STACKS
- { stackKey = "trinket2", disabled = true },
- { stackKey = "combatPotions" },
- { kind = "spell", ids = { 59752 } }, -- racial (self-contained)
- },
- main = {},
- },
-}
-```
-
-**Settings UI (`UI/ExtraIconsOptions.lua`):**
-
-Registers through the root/section/page API and exposes only native controls plus the single viewer-management section list. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) and page setup hooks (`SetRegisteredPage`, `EnsureItemLoadFrame`, `BuildSections`, `ResetToDefaults`) are exposed on `ns.ExtraIconsOptions` for testability and options bootstrap.
-
-*Row rendering and add flow.* Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows.
-
-*Built-in rows.* Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section.
-
-*Trinket and equip-slot filtering.* Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Equip-slot placeholders follow the same on-use filter, and trinket-slot equipment changes refresh the page so the visible rows stay in sync.
+### Module reference docs
-*Racials.* The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
+Per-module surface (config, events, hooks, internal state, options) lives with each module:
-*Lifecycle.* Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+- [docs/PowerBar.md](docs/PowerBar.md)
+- [docs/ResourceBar.md](docs/ResourceBar.md)
+- [docs/RuneBar.md](docs/RuneBar.md)
+- [docs/BuffBars.md](docs/BuffBars.md)
+- [docs/ExternalBars.md](docs/ExternalBars.md)
+- [docs/ExtraIcons.md](docs/ExtraIcons.md)
### FrameUtil (`ns.FrameUtil`)
diff --git a/Libs/LibSettingsBuilder/README.md b/Libs/LibSettingsBuilder/README.md
index 4c1b2f39..eccb08d9 100644
--- a/Libs/LibSettingsBuilder/README.md
+++ b/Libs/LibSettingsBuilder/README.md
@@ -31,18 +31,18 @@ The runtime returned by `LSB.New(...)` is intentionally narrow. Builder/helper c
## At a glance
-| Need | LibSettingsBuilder |
-|---|---|
-| Standard settings pages | `LSB.New({ name = ..., page = ..., sections = ... })` |
-| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec |
-| Dynamic refresh | lookup the registered page with `lsb:GetRootPage()` / `lsb:GetPage(...)`, then call `page:Refresh()` |
-| Existing AceDB profiles | `store = db.profile`, `defaults = defaults.profile` |
-| Custom storage | handler mode with `get` / `set` / `key` (or `id`) |
-| Text entry / numeric ID fields | `type = "input"` |
-| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
-| Reusable settings groups | border, font override, and height override composites |
-| XML-backed bespoke widgets | `type = "custom"` |
-| Force visible rows to refresh | `page:Refresh()` |
+| Need | LibSettingsBuilder |
+| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
+| Standard settings pages | `LSB.New({ name = ..., page = ..., sections = ... })` |
+| Root-owned landing page | `page = { key = ..., rows = ... }` inside the root spec |
+| Dynamic refresh | lookup the registered page with `lsb:GetRootPage()` / `lsb:GetPage(...)`, then call `page:Refresh()` |
+| Existing AceDB profiles | `store = db.profile`, `defaults = defaults.profile` |
+| Custom storage | handler mode with `get` / `set` / `key` (or `id`) |
+| Text entry / numeric ID fields | `type = "input"` |
+| Dynamic editors / ordered lists | `type = "list"` or `type = "sectionList"` |
+| Reusable settings groups | border, font override, and height override composites |
+| XML-backed bespoke widgets | `type = "custom"` |
+| Force visible rows to refresh | `page:Refresh()` |
## Quick start
@@ -102,27 +102,27 @@ For a registered category tree, `name` and `onChanged` are required. `store` ena
Declarative pages accept canonical row types only.
-| Type | Meaning |
-|---|---|
-| `checkbox` | Boolean proxy setting |
-| `slider` | Numeric proxy setting |
-| `dropdown` | Deterministic menu proxy setting |
-| `input` | Built-in text input row with optional preview / debounce support |
-| `color` | Color swatch proxy setting |
-| `button` | Button row |
-| `header` | Blizzard-style section header |
-| `subheader` | Secondary text row |
-| `info` | Left-label / right-value informational row |
-| `canvas` | Embedded frame row for canvas content |
-| `pageActions` | Right-aligned page-header action row |
-| `list` | First-class dynamic flat list widget |
-| `sectionList` | First-class dynamic grouped list widget |
-| `custom` | Proxy setting backed by a custom XML template |
-| `colorList` | Expands `defs` into multiple color swatches |
-| `checkboxList` | Expands `defs` into multiple checkboxes |
-| `border` | Composite group for border enable / width / color |
-| `fontOverride` | Composite group for override toggle, font picker, and size slider |
-| `heightOverride` | Composite slider with nil/zero transforms |
+| Type | Meaning |
+| ---------------- | ----------------------------------------------------------------- |
+| `checkbox` | Boolean proxy setting |
+| `slider` | Numeric proxy setting |
+| `dropdown` | Deterministic menu proxy setting |
+| `input` | Built-in text input row with optional preview / debounce support |
+| `color` | Color swatch proxy setting |
+| `button` | Button row |
+| `header` | Blizzard-style section header |
+| `subheader` | Secondary text row |
+| `info` | Left-label / right-value informational row |
+| `canvas` | Embedded frame row for canvas content |
+| `pageActions` | Right-aligned page-header action row |
+| `list` | First-class dynamic flat list widget |
+| `sectionList` | First-class dynamic grouped list widget |
+| `custom` | Proxy setting backed by a custom XML template |
+| `colorList` | Expands `defs` into multiple color swatches |
+| `checkboxList` | Expands `defs` into multiple checkboxes |
+| `border` | Composite group for border enable / width / color |
+| `fontOverride` | Composite group for override toggle, font picker, and size slider |
+| `heightOverride` | Composite slider with nil/zero transforms |
## Input rows
diff --git a/docs/BuffBars.md b/docs/BuffBars.md
new file mode 100644
index 00000000..b23b38c7
--- /dev/null
+++ b/docs/BuffBars.md
@@ -0,0 +1,297 @@
+# BuffBars
+
+## Overview
+
+| Field | Details |
+|---|---|
+| **Module name** | `BuffBars` |
+| **Description** | Mirrors Blizzard's `BuffBarCooldownViewer` area into ECM-styled aura bars. ECM repositions and restyles Blizzard-owned child bars instead of creating its own aura rows. |
+| **Source file** | [`Modules/BuffBars.lua`](../Modules/BuffBars.lua) |
+| **Mixin** | `BarMixin.AddFrameMixin(self, "BuffBars")` using `BarMixin.FrameProto` methods such as `EnsureFrame()`, `GetModuleConfig()`, `ShouldShow()`, and `CalculateLayoutParams()`. |
+| **Events listened to** | - `ZONE_CHANGED_NEW_AREA` — refreshes zone-specific Blizzard aura bars and requests layout.
- `ZONE_CHANGED` — refreshes zone changes that can alter the viewer's child set.
- `ZONE_CHANGED_INDOORS` — refreshes indoor/outdoor aura transitions.
- `PLAYER_ENTERING_WORLD` — catches initial world entry and reload/login transitions. |
+| **Hooks** | - `BuffBarCooldownViewer:OnShow` — requests a layout pass when Blizzard re-shows the viewer.
- `BuffBarCooldownViewer:OnSizeChanged` — requests a second-pass layout when Blizzard changes viewer width/size.
- `child:SetPoint` — restores ECM's cached anchors, restyles the child, and queues a second-pass layout.
- `child:OnShow` — reapplies ECM styling and queues a second-pass layout.
- `child:OnHide` — queues a second-pass layout so the remaining bars restack cleanly. |
+| **Dependencies** | - `ns.BarMixin` / `BarMixin.FrameProto` — frame-module lifecycle, config access, anchor calculation.
- `ns.Runtime` — frame registration plus `RequestLayout()` / layout execution.
- `ns.BarStyle.StyleChildBar` — applies ECM visuals to Blizzard child bars.
- `ns.FrameUtil` — lazy anchors, width snapshots, icon texture lookup.
- `ns.SpellColors.Get("buffBars")` — scoped spell-color discovery, lookup, and cache clearing.
- `ns.Constants` / `ns.defaults` — scope name, anchor-mode semantics, default colors/config.
- Blizzard `BuffBarCooldownViewer` and its child aura-bar frames — source viewer and mirrored rows.
- `C_Timer.After(0.1)` — deferred hook install so the Blizzard viewer exists before BuffBars attaches hooks. |
+| **Options file(s)** | [`UI/BuffBarsOptions.lua`](../UI/BuffBarsOptions.lua), plus BuffBars' section registration into [`UI/SpellColorsPage.lua`](../UI/SpellColorsPage.lua) |
+| **Options dependencies** | - `ns.OptionUtil`
- `LibSettingsBuilder`
- `ns.SpellColors`
- `ns.SpellColorsPage` |
+
+## Actor flow
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game (WoW client)
+ participant ECM as ECM
+ participant Runtime as Runtime
+ participant BuffBars as BuffBars
+ participant Viewer as BuffBarCooldownViewer
+ participant Child as Blizzard aura child
+ participant Colors as SpellColors store (buffBars)
+ participant Options as Options UI
+ participant EM as Edit Mode
+
+ rect rgb(26,26,46)
+ Note over Game,Colors: Startup / enable
+ Game->>ECM: ADDON_LOADED / PLAYER_LOGIN
+ ECM->>BuffBars: OnInitialize()
+ BuffBars->>BuffBars: BarMixin.AddFrameMixin(self, "BuffBars")
+ ECM->>Runtime: Runtime.Enable(addon)
+ Runtime->>BuffBars: EnableModule("BuffBars")
+ BuffBars->>Viewer: EnsureFrame() -> CreateFrame()
+ BuffBars->>Runtime: RegisterFrame(self)
+ BuffBars->>Game: RegisterEvent(ZONE_CHANGED_*, PLAYER_ENTERING_WORLD)
+ BuffBars->>BuffBars: C_Timer.After(0.1)
+ BuffBars->>BuffBars: HookViewer()
+ BuffBars->>Runtime: RequestLayout("BuffBars:ModuleInit")
+ end
+
+ rect rgb(26,46,30)
+ Note over Game,Colors: Registered BuffBars events
+ Game->>BuffBars: ZONE_CHANGED_NEW_AREA
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnZoneChanged")
+ Game->>BuffBars: ZONE_CHANGED
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnZoneChanged")
+ Game->>BuffBars: ZONE_CHANGED_INDOORS
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnZoneChanged")
+ Game->>BuffBars: PLAYER_ENTERING_WORLD
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnZoneChanged")
+ end
+
+ rect rgb(46,30,46)
+ Note over Game,Colors: Viewer and child hooks
+ Viewer-->>BuffBars: OnShow
+ BuffBars->>Runtime: RequestLayout("BuffBars:viewer:OnShow")
+ Viewer-->>BuffBars: OnSizeChanged
+ BuffBars->>Runtime: RequestLayout("BuffBars:viewer:OnSizeChanged", { secondPass = true })
+ Child-->>BuffBars: SetPoint
+ BuffBars->>Child: LazySetAnchors(child.__ecmAnchorCache)
+ BuffBars->>Child: StyleChildBar(...)
+ BuffBars->>Runtime: RequestLayout("BuffBars:SetPoint:hook", { secondPass = true })
+ Child-->>BuffBars: OnShow
+ BuffBars->>Child: StyleChildBar(...)
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnShow:child", { secondPass = true })
+ Child-->>BuffBars: OnHide
+ BuffBars->>Runtime: RequestLayout("BuffBars:OnHide:child", { secondPass = true })
+ end
+
+ rect rgb(30,30,60)
+ Note over Game,Colors: Layout and spell-color discovery
+ Runtime->>BuffBars: UpdateLayout(reason)
+ alt reason is PLAYER_SPECIALIZATION_CHANGED or ProfileChanged
+ BuffBars->>Colors: ClearDiscoveredKeys()
+ end
+ BuffBars->>Viewer: GetChildren()
+ loop each ordered visible child
+ BuffBars->>Colors: DiscoverBar(child)
+ BuffBars->>Child: Hook child once
+ BuffBars->>Child: StyleChildBar(module, child, cfg, globalCfg, Colors)
+ end
+ alt module hidden
+ BuffBars->>Viewer: Hide()
+ else module shown
+ BuffBars->>Viewer: LazySetAnchors(...) or LazySetWidth(...)
+ BuffBars->>Child: layoutBars(...)
+ BuffBars->>Viewer: Show()
+ end
+ end
+
+ rect rgb(46,40,26)
+ Note over Game,Colors: Profile and options changes
+ ECM->>Runtime: ScheduleLayoutUpdate(0, "ProfileChanged")
+ Runtime->>BuffBars: UpdateLayout("ProfileChanged")
+ BuffBars->>Colors: ClearDiscoveredKeys()
+ Options->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Runtime->>BuffBars: UpdateLayout("OptionsChanged")
+ end
+
+ rect rgb(26,40,46)
+ Note over Game,Colors: Spell Colors page
+ Options->>Colors: GetAllColorEntries() / GetDefaultColor()
+ Options->>BuffBars: IsEditLocked()
+ alt user changes a color
+ Options->>Colors: SetColorByKey(...) / SetDefaultColor(...)
+ Options->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ else user resets or removes stale keys
+ Options->>Colors: ClearCurrentSpecColors() / RemoveEntriesByKeys(...)
+ Options->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ end
+ end
+
+ rect rgb(46,26,30)
+ Note over Game,Colors: Edit Mode
+ EM->>Runtime: ScheduleLayoutUpdate(0, "EditModeEnter")
+ Runtime->>BuffBars: UpdateLayout("EditModeEnter")
+ Note over EM,BuffBars: BuffBars overrides ShouldRegisterEditMode() = false because registering the Blizzard viewer taints Blizzard Edit Mode selection.
+ EM->>Runtime: UpdateLayoutImmediately("EditModeDrag")
+ Runtime->>BuffBars: UpdateLayout("EditModeDrag")
+ EM->>Runtime: ScheduleLayoutUpdate(0, "EditModeExit")
+ end
+```
+
+## Component interactions
+
+```mermaid
+flowchart TD
+ Runtime[Runtime.lua]
+ BuffBars[BuffBars module]
+ Viewer[BuffBarCooldownViewer]
+ Child[Blizzard aura child frames]
+ BarMixin[BarMixin.FrameProto]
+ BarStyle[BarStyle.StyleChildBar]
+ FrameUtil[FrameUtil]
+ SpellStore[SpellColors store\nscope = "buffBars"]
+ Options[BuffBarsOptions + SpellColorsPage]
+ ECM[ECM.lua]
+
+ subgraph Blizzard[Blizzard frames being mirrored]
+ Viewer
+ Child
+ end
+
+ subgraph Internals[ECM internals]
+ ECM
+ Runtime
+ BuffBars
+ BarMixin
+ Options
+ end
+
+ subgraph Helpers[Shared helpers]
+ BarStyle
+ FrameUtil
+ SpellStore
+ end
+
+ ECM -->|Runtime.Enable / profile callbacks| Runtime
+ Runtime -->|EnableModule / RegisterFrame / UpdateLayout| BuffBars
+ Runtime -->|shared layout events, edit-mode visibility, second pass| BuffBars
+ BuffBars -->|CreateFrame / mirror viewer| Viewer
+ Viewer -->|owns / creates| Child
+ Viewer -->|OnShow / OnSizeChanged hooks| BuffBars
+ Child -->|SetPoint / OnShow / OnHide hooks| BuffBars
+ BuffBars -->|frame lifecycle + config lookup + layout params| BarMixin
+ BuffBars -->|style each mirrored child| BarStyle
+ BuffBars -->|lazy anchors, width, icon texture ids| FrameUtil
+ BuffBars -->|Get("buffBars"), DiscoverBar, color lookup, cache clear| SpellStore
+ Options -->|module settings rows| BuffBars
+ Options -->|shared spell-color section| SpellStore
+ Options -->|OptionsChanged -> schedule layout| Runtime
+
+ style Blizzard fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style Internals fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style Helpers fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+```
+
+## Data model
+
+```mermaid
+classDiagram
+ class ECM_Profile {
+ +buffBars: ECM_BuffBarsConfig
+ }
+
+ class ECM_BuffBarsConfig {
+ +enabled: boolean
+ +anchorMode: string
+ +editModePositions: table
+ +verticalSpacing: number
+ +showIcon: boolean
+ +showSpellName: boolean
+ +showDuration: boolean
+ +overrideFont: boolean
+ +font: string?
+ +fontSize: number?
+ +colors: ECM_SpellColorsConfig
+ }
+
+ class ECM_SpellColorsConfig {
+ +byName: table
+ +bySpellID: table
+ +byCooldownID: table
+ +byTexture: table
+ +cache: table
+ +defaultColor: ECM_Color
+ }
+
+ class FrameProto {
+ +InnerFrame: Frame
+ +Name: string
+ +_configKey: string
+ +IsHidden: boolean
+ +EnsureFrame()
+ +GetModuleConfig()
+ +ShouldShow()
+ +CalculateLayoutParams()
+ +SetHidden(hidden)
+ }
+
+ class BuffBars {
+ +ShouldRegisterEditMode()
+ +CreateFrame()
+ +IsReady()
+ +UpdateLayout(why)
+ +GetActiveSpellData()
+ +HookViewer()
+ +OnZoneChanged()
+ +IsEditLocked()
+ +_viewerHooked: boolean
+ +_layoutRunning: boolean?
+ +_warned: boolean
+ +_editLocked: boolean?
+ }
+
+ class ECM_SpellColorStore {
+ +GetAllColorEntries()
+ +GetColorByKey(key)
+ +GetDefaultColor()
+ +SetColorByKey(key, color)
+ +SetDefaultColor(color)
+ +DiscoverBar(frame)
+ +ClearDiscoveredKeys()
+ +ClearCurrentSpecColors()
+ +RemoveEntriesByKeys(keys)
+ }
+
+ class ECM_BuffBarMixin {
+ +__ecmHooked: boolean
+ +Bar: StatusBar
+ +DebuffBorder: Region
+ +Icon: Frame
+ +ignoreInLayout: boolean?
+ +layoutIndex: number?
+ +cooldownID: number?
+ +cooldownInfoSpellID: number?
+ +__ecmAnchorCache: table?
+ }
+
+ class BuffBarCooldownViewer {
+ +baseBarWidth: number?
+ +barWidthScale: number?
+ +GetChildren()
+ +GetPoint(index)
+ }
+
+ class BarStyle {
+ +StyleChildBar(module, frame, config, globalConfig, spellColors)
+ }
+
+ class FrameUtil {
+ +LazySetAnchors(frame, anchors)
+ +LazySetWidth(frame, width)
+ +GetIconTextureFileID(frame)
+ }
+
+ FrameProto <|-- BuffBars : mixed in via AddFrameMixin
+ ECM_Profile *-- ECM_BuffBarsConfig : buffBars
+ ECM_BuffBarsConfig *-- ECM_SpellColorsConfig : colors
+ BuffBars --> ECM_SpellColorStore : uses scope "buffBars"
+ BuffBars --> BuffBarCooldownViewer : InnerFrame / mirrored viewer
+ BuffBarCooldownViewer *-- ECM_BuffBarMixin : Blizzard-owned child rows
+ BuffBars --> BarStyle : styles child bars
+ BuffBars --> FrameUtil : lazy frame writes
+```
+
+## Notes
+
+- BuffBars does **not** pool or create its own child bars; it mirrors Blizzard-owned viewer children and re-applies ECM anchors/styles around them.
+- `anchorMode = "free"` is special: Blizzard keeps owning the viewer's position, while ECM snapshots width and still restacks child rows.
+- The shared Spell Colors page is shared with `ExternalBars`, but each module keeps a separate scoped store (`"buffBars"` vs. `"externalBars"`).
diff --git a/docs/ExternalBars.md b/docs/ExternalBars.md
new file mode 100644
index 00000000..d6aab565
--- /dev/null
+++ b/docs/ExternalBars.md
@@ -0,0 +1,278 @@
+## ExternalBars
+
+`ExternalBars` mirrors Blizzard's `ExternalDefensivesFrame` into ECM-owned bar rows. Unlike the aura-driven modules, it does not register its own aura events; Blizzard remains the authoritative source and ECM follows via hooks.
+
+This module is intentionally absent from the `ARCHITECTURE.md` Event Reference table because its data flow is hook-driven, not event-driven.
+
+## Summary
+
+| Field | Value |
+|---|---|
+| **Module name** | `ExternalBars` |
+| **Description** | Mirrors `ExternalDefensivesFrame` into ECM-styled bar rows. Hook-driven rather than event-driven: Blizzard populates `viewer.auraInfo`, and ECM copies that state into its own pooled rows. |
+| **Source file** | [`Modules/ExternalBars.lua`](../Modules/ExternalBars.lua) |
+| **Mixin** | `BarMixin.AddFrameMixin`; inherits [`FrameProto`](../BarMixin.lua) |
+| **Events listened to** | None for aura data. `ExternalBars` does not call `RegisterEvent()` in `Modules/ExternalBars.lua`; aura refresh is driven by hooks on `ExternalDefensivesFrame:UpdateAuras()` plus the frame's `OnShow` / `OnHide`. `OnDisable()` still calls `UnregisterAllEvents()` as defensive cleanup. |
+| **Hooks** | - Post-hook `ExternalDefensivesFrame:UpdateAuras()` → `OnExternalAurasUpdated()`
- `ExternalDefensivesFrame:HookScript("OnShow")` → refresh original-icon state, then resync aura state
- `ExternalDefensivesFrame:HookScript("OnHide")` → clear active rows, stop duration ticker, request layout
- `hideOriginalIcons` uses `ExternalDefensivesFrame:SetAlpha(0)` and `EnableMouse(false)` instead of `Hide()` so Blizzard keeps driving `UpdateAuras()` |
+| **Dependencies** | - `ns.SpellColors.Get("externalBars")` scoped color store
- `BarStyle.StyleChildBar(...)` shared BuffBars / ExternalBars row styling
- `Cooldown` overlays via `SetCooldownDuration(duration, timeMod)` for draining fill
- `C_UnitAuras.GetAuraDataByAuraInstanceID("player", auraInstanceID)` for accessible aura metadata
- `FrameUtil` lazy setters and icon helpers
- `ns.Runtime.RequestLayout(...)` / runtime layout passes |
+| **Options file(s)** | [`UI/ExternalBarsOptions.lua`](../UI/ExternalBarsOptions.lua), shared section registration in [`UI/SpellColorsPage.lua`](../UI/SpellColorsPage.lua) |
+| **Options dependencies** | - `ns.OptionUtil` for disabled predicates, default-value transforms, module toggle handling, and layout breadcrumbs
- `LibSettingsBuilder` for the declarative Settings rows consumed by the root options tree
- `ns.SpellColors` for the scoped color store edited by the shared page
- `ns.SpellColorsPage` for `RegisterSection(...)` and the shared spell-colors editor |
+
+## Actor diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game (WoW client)
+ participant ACE as ACE / ECM
+ participant Runtime as Runtime
+ participant EB as ExternalBars
+ participant Viewer as ExternalDefensivesFrame
+ participant AuraAPI as C_UnitAuras
+ participant Colors as SpellColors scope=externalBars
+ participant UI as OptionsUI
+ participant EditMode as LibEditMode
+
+ rect rgb(26,26,46)
+ note over Game,Colors: Startup / enable
+ Game->>ACE: ADDON_LOADED / PLAYER_LOGIN
+ ACE->>EB: OnInitialize()
+ EB->>EB: BarMixin.AddFrameMixin(self, "ExternalBars")
+ ACE->>EB: OnEnable()
+ EB->>EB: EnsureFrame()
+ EB->>Runtime: RegisterFrame(self)
+ EB->>EB: Init _barPool / _activeBars / _auraStates
+ EB->>EB: C_Timer.After(0.1)
+ EB->>EB: HookViewer()
+ EB->>Viewer: hooksecurefunc(UpdateAuras)
+ EB->>Viewer: HookScript(OnShow / OnHide)
+ EB->>EB: _RefreshOriginalIconsState()
+ EB->>EB: OnExternalAurasUpdated()
+ EB->>Runtime: RequestLayout("ExternalBars:OnEnable")
+ end
+
+ rect rgb(46,30,46)
+ note over Viewer,Colors: Blizzard viewer pushes aura updates
+ Viewer-->>EB: UpdateAuras() post-hook
+ EB->>EB: OnExternalAurasUpdated()
+ loop for each viewer.auraInfo[index]
+ EB->>AuraAPI: GetAuraDataByAuraInstanceID("player", auraInstanceID)
+ AuraAPI-->>EB: accessible aura metadata
+ EB->>EB: copy into _auraStates[index]
+ end
+ EB->>Runtime: RequestLayout("ExternalBars:UpdateAuras")
+ end
+
+ rect rgb(30,30,60)
+ note over Viewer,Runtime: Viewer visibility hooks
+ Viewer-->>EB: OnShow
+ EB->>EB: _RefreshOriginalIconsState()
+ EB->>EB: OnExternalAurasUpdated()
+ EB->>Runtime: RequestLayout("ExternalBars:UpdateAuras")
+ Viewer-->>EB: OnHide
+ EB->>EB: _activeAuraCount = 0; _hideExcessBars(0)
+ EB->>EB: _StopDurationTicker()
+ EB->>Runtime: RequestLayout("ExternalBars:viewer:OnHide")
+ end
+
+ rect rgb(26,46,30)
+ note over Runtime,Colors: Runtime layout pulse
+ Runtime->>EB: UpdateLayout(reason)
+ EB->>EB: HookViewer(); _RefreshOriginalIconsState()
+ alt reason == PLAYER_SPECIALIZATION_CHANGED or ProfileChanged
+ EB->>Colors: ClearDiscoveredKeys()
+ end
+ loop for each active aura index
+ EB->>EB: _ensureBar(index)
+ EB->>EB: _ConfigureBar(bar, auraState, ...)
+ EB->>Colors: DiscoverBar(bar)
+ end
+ EB->>EB: layoutBars(activeBars, InnerFrame, ...)
+ EB->>EB: _RestartDurationTicker()
+ end
+
+ rect rgb(46,40,26)
+ note over Game,Colors: Profile change
+ Game->>ACE: switch / copy / reset profile
+ ACE->>Runtime: Enable(addon) + ScheduleLayoutUpdate(0, "ProfileChanged")
+ Runtime->>EB: UpdateLayout("ProfileChanged")
+ EB->>Colors: ClearDiscoveredKeys()
+ end
+
+ rect rgb(46,26,30)
+ note over UI,Runtime: Options change
+ UI->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Runtime->>EB: UpdateLayout("OptionsChanged")
+ end
+
+ rect rgb(26,40,46)
+ note over EditMode,Runtime: Edit Mode
+ EditMode->>Runtime: ScheduleLayoutUpdate(0, "EditModeEnter")
+ Runtime->>EB: UpdateLayout("EditModeEnter")
+ EditMode->>Runtime: UpdateLayoutImmediately("EditModeDrag")
+ Runtime->>EB: UpdateLayout("EditModeDrag")
+ EditMode->>Runtime: ScheduleLayoutUpdate(0, "EditModeExit")
+ Runtime->>EB: UpdateLayout("EditModeExit")
+ end
+```
+
+## Component interaction diagram
+
+```mermaid
+flowchart TD
+ subgraph BLIZZARD["Blizzard frames mirrored"]
+ Viewer["ExternalDefensivesFrame\n(authoritative aura source)"]
+ AuraInfo["viewer.auraInfo[]"]
+ AuraAPI["C_UnitAuras\nGetAuraDataByAuraInstanceID"]
+ end
+
+ subgraph ECM["ECM internals"]
+ ExternalBars["ExternalBars"]
+ Runtime["Runtime"]
+ Options["ExternalBarsOptions\n+ SpellColorsPage section"]
+ end
+
+ subgraph HELPERS["Shared helpers"]
+ Cooldown["Cooldown overlays"]
+ BarStyle["BarStyle.StyleChildBar"]
+ FrameUtil["FrameUtil"]
+ SpellColors["SpellColors store\nscope = \"externalBars\""]
+ end
+
+ Viewer -->|UpdateAuras post-hook| ExternalBars
+ Viewer -->|OnShow / OnHide hooks| ExternalBars
+ Viewer --> AuraInfo
+ AuraInfo -->|copy by Blizzard array index| ExternalBars
+ ExternalBars -->|metadata enrichment| AuraAPI
+ Runtime -->|UpdateLayout(reason)| ExternalBars
+ Options -->|OptionsChanged / shared spell-color UI| Runtime
+ Options -->|edit scoped colors| SpellColors
+ ExternalBars -->|RequestLayout(...)| Runtime
+ ExternalBars -->|StyleChildBar(...)| BarStyle
+ ExternalBars -->|LazySetWidth / Height / Anchors| FrameUtil
+ ExternalBars -->|Get + DiscoverBar + ClearDiscoveredKeys| SpellColors
+ ExternalBars -->|SetCooldownDuration / Clear| Cooldown
+ BarStyle --> FrameUtil
+ BarStyle --> SpellColors
+
+ style BLIZZARD fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style ECM fill:#16213e,stroke:#22c55e,color:#e0e0e0
+ style HELPERS fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+```
+
+## Data model class diagram
+
+```mermaid
+classDiagram
+ class ECM_Profile {
+ +externalBars: ECM_ExternalBarsConfig
+ }
+
+ class ECM_ExternalBarsConfig {
+ +enabled: boolean
+ +hideOriginalIcons: boolean
+ +anchorMode: string
+ +editModePositions: table
+ +width: number
+ +height: number
+ +verticalSpacing: number
+ +showIcon: boolean
+ +showSpellName: boolean
+ +showDuration: boolean
+ +overrideFont: boolean
+ +colors: ECM_ExternalBarsColorsConfig
+ }
+
+ class ECM_ExternalBarsColorsConfig {
+ +byName: table
+ +bySpellID: table
+ +byCooldownID: table
+ +byTexture: table
+ +cache: table
+ +defaultColor: table
+ }
+
+ class FrameProto {
+ +EnsureFrame()
+ +ApplyFramePosition()
+ +ShouldShow()
+ +SetHidden()
+ }
+
+ class ExternalBars {
+ +InnerFrame: Frame
+ +_barPool: ECM_ExternalBarMixin[]
+ +_activeBars: ECM_ExternalBarMixin[]
+ +_auraStates: ECM_ExternalAuraState[]
+ +_activeAuraCount: number
+ +_viewerHooked: boolean
+ +_originalIconsHidden: boolean
+ +_editLocked: boolean
+ +HookViewer()
+ +OnExternalAurasUpdated()
+ +UpdateLayout(why)
+ +GetActiveSpellData()
+ }
+
+ class ECM_ExternalAuraState {
+ +index: number
+ +auraInstanceID: number
+ +name: string
+ +spellID: number
+ +texture: string|number
+ +duration: number
+ +expirationTime: number
+ +timeMod: number
+ +durationIsSecret: boolean
+ +canShowDurationText: boolean
+ +hasRenderableDuration: boolean
+ }
+
+ class ECM_ExternalBarMixin {
+ +Bar: ECM_ExternalBarStatusBar
+ +Cooldown: Cooldown
+ +Icon: Frame
+ +cooldownSpellID: number
+ +_ecmAuraIndex: number
+ +_iconTexture: Texture
+ }
+
+ class ECM_ExternalBarStatusBar {
+ +Name: FontString
+ +Duration: FontString
+ +Pip: Texture
+ }
+
+ class Cooldown {
+ +SetCooldownDuration(duration, timeMod)
+ +Clear()
+ }
+
+ class BarStyle {
+ +StyleChildBar(module, frame, config, globalConfig, spellColors)
+ }
+
+ class ECM_SpellColorStore {
+ +GetColorForBar(frame)
+ +DiscoverBar(frame)
+ +ClearDiscoveredKeys()
+ +GetDefaultColor()
+ }
+
+ ECM_Profile *-- ECM_ExternalBarsConfig
+ ECM_ExternalBarsConfig *-- ECM_ExternalBarsColorsConfig
+ FrameProto <|-- ExternalBars
+ ExternalBars *-- ECM_ExternalAuraState : _auraStates[index]
+ ExternalBars *-- ECM_ExternalBarMixin : _barPool[index]
+ ECM_ExternalBarMixin *-- ECM_ExternalBarStatusBar
+ ECM_ExternalBarMixin *-- Cooldown
+ ExternalBars ..> BarStyle : styles rows
+ ExternalBars ..> ECM_SpellColorStore : scope "externalBars"
+```
+
+## Notes
+
+- `_auraStates[]` is keyed by Blizzard's `viewer.auraInfo` array index, not by `auraInstanceID`. `auraInstanceID` is preserved only for Blizzard aura API lookups.
+- `hideOriginalIcons` is deliberately implemented as alpha and mouse suppression, not `Hide()`, so Blizzard continues to execute `ExternalDefensivesFrame:UpdateAuras()`.
+- `UpdateLayout("PLAYER_SPECIALIZATION_CHANGED")` and `UpdateLayout("ProfileChanged")` both clear discovered spell-color keys for the `externalBars` scope before restyling rows.
+- Duration fill and duration text are separate paths: the `Cooldown` widget can render secret durations, while Lua text refresh only runs when expiration data is safe to inspect directly.
diff --git a/docs/ExtraIcons.md b/docs/ExtraIcons.md
new file mode 100644
index 00000000..de1d2027
--- /dev/null
+++ b/docs/ExtraIcons.md
@@ -0,0 +1,313 @@
+# ExtraIcons
+
+## Summary
+
+| Field | Details |
+|---|---|
+| **Module name** | `ExtraIcons` |
+| **Description** | Displays extra cooldown-tracked icons beside Blizzard's cooldown viewers. It uses a dual-viewer architecture (`utility` and `main`) so icons can either extend the essential viewer footprint or live beside the utility viewer independently. |
+| **Source file** | [`Modules/ExtraIcons.lua`](../Modules/ExtraIcons.lua) |
+| **Mixin** | `BarMixin.AddFrameMixin`; inherits [`BarMixin.FrameProto`](../BarMixin.lua) |
+| **Events listened to** | - `BAG_UPDATE_COOLDOWN` — throttled icon cooldown refresh for item/spell state changes.
- `BAG_UPDATE_DELAYED` — request a fresh layout after bag contents settle.
- `PLAYER_EQUIPMENT_CHANGED` — re-evaluate tracked equip-slot entries (notably trinkets) and request layout when a referenced slot changed.
- `PLAYER_ENTERING_WORLD` — request a full layout refresh after world entry.
- `SPELLS_CHANGED` — request layout when known spells change.
- `SPELL_UPDATE_COOLDOWN` — throttled spell cooldown refresh.
- `UtilityCooldownViewer` / `EssentialCooldownViewer` `OnShow` — request layout when a Blizzard viewer becomes visible.
- `UtilityCooldownViewer` / `EssentialCooldownViewer` `OnHide` — hide extra-icon containers/anchors and request layout.
- `UtilityCooldownViewer` / `EssentialCooldownViewer` `OnSizeChanged` — request layout when Blizzard viewer width changes.
- `EditModeManagerFrame` `OnShow` / `OnHide` — enter/exit edit-mode layout behavior.
- `GET_ITEM_INFO_RECEIVED` — options-page async draft-item resolution refresh.
- `PLAYER_EQUIPMENT_CHANGED` (options item-load frame) — refresh built-in equip-slot rows when tracked gear changes.
|
+| **Layout ordering** | `ExtraIcons:UpdateLayout()` runs first inside `Runtime.updateAllLayouts()`, so the main viewer anchor reflects Blizzard icons plus appended extra icons before chained bars compute their own positions. |
+| **Dependencies** | - `ns.BarMixin` / `BarMixin.FrameProto` — frame lifecycle, visibility, config access.
- `ns.Runtime` — registration, layout requests, preview state.
- `ns.Constants.BUILTIN_STACKS` — built-in stack definitions resolved by `stackKey`.
- `ns.Constants.BUILTIN_STACK_ORDER` — canonical built-in row ordering: `trinket1`, `trinket2`, `combatPotions`, `healthPotions`, `healthstones`.
- `ns.Constants.RACIAL_ABILITIES` — current-race racial placeholder synthesis in the options UI.
- `ns.FrameUtil` — inherited lazy layout helpers through `FrameProto` / runtime-owned layout code.
- `ns.OptionUtil` and `ns.ExtraIconsOptions` — settings UI actions, confirmation flow, section-list wiring.
- `ns.Addon.db.profile.extraIcons` — persisted config source of truth.
|
+| **Blizzard APIs used** | - `UtilityCooldownViewer`, `EssentialCooldownViewer` — Blizzard viewer frames being extended and re-anchored.
- `GetInventoryItemID`, `GetInventoryItemCooldown`, `GetInventoryItemTexture` — equip-slot resolution and cooldown display.
- `C_Item.GetItemSpell`, `C_Item.GetItemCount`, `C_Item.GetItemCooldown` — item usability / cooldown resolution.
- `C_Item.GetItemIconByID`, `C_Item.GetItemNameByID`, `C_Item.DoesItemExistByID`, `C_Item.RequestLoadItemDataByID` — options-page item preview and async item-load flow.
- `C_SpellBook.IsSpellKnown` — spell resolver gate.
- `C_Spell.GetSpellTexture`, `C_Spell.GetSpellCooldown` — spell icon and cooldown lookup (`GetSpellCooldown` stays pass-through / secret-value safe).
- `C_Spell.GetSpellCharges`, `C_Spell.GetSpellChargeDuration`, `C_Spell.GetSpellCooldownDuration` — charge-aware cooldown rendering.
- `UnitRace` — current-player racial placeholder resolution.
|
+| **Options file(s)** | [`UI/ExtraIconsOptions.lua`](../UI/ExtraIconsOptions.lua) |
+| **Options dependencies** | - `ns.OptionUtil` — enable/disable helpers and confirmation dialog plumbing.
- `LibSettingsBuilder` section-list row (`type = "sectionList"`) — viewer collections and inline add rows.
- Async item-info flow backed by `GET_ITEM_INFO_RECEIVED` and `C_Item.RequestLoadItemDataByID`.
|
+
+## Architecture Notes
+
+### ExtraIcons (`Modules/ExtraIcons.lua`)
+
+Displays cooldown-tracked icons alongside Blizzard's cooldown viewer frames. Uses a dual-viewer architecture with a stack-aware resolver.
+
+**Viewer Registry:** Maps abstract viewer keys to Blizzard frame globals. Current keys: `"utility"` → `UtilityCooldownViewer`, `"main"` → `EssentialCooldownViewer`. Each viewer has its own container frame, on-demand icon pool, and hook set. The main viewer's expanded footprint also drives a shared midpoint offset for the two-viewer layout; utility applies that pair offset first, then layers its own local centering so moving icons between viewers preserves the combined layout.
+
+**Entry Kinds and Resolution:**
+
+| Kind | Config Fields | Resolution | Cooldown Source |
+|------|--------------|------------|-----------------|
+| `equipSlot` | `slotId` | `GetInventoryItemID` + `C_Item.GetItemSpell` on-use check | `GetInventoryItemCooldown` |
+| `item` | `ids[]` (priority stack) | First with `C_Item.GetItemCount > 0` | `C_Item.GetItemCooldown` |
+| `spell` | `ids[]` (priority stack) | First known via `C_SpellBook.IsSpellKnown`, then `C_Spell.GetSpellTexture` | `C_Spell.GetSpellCooldown` (pass-through, no inspection) |
+
+Predefined stacks (`BUILTIN_STACKS`) are referenced by `stackKey` in config; the resolver reads `kind`/`ids`/`slotId` from the constant at runtime. Built-in entries may also persist `disabled = true`, which keeps them in the settings list but skips them during runtime resolution. Custom and racial entries store fields directly in saved config.
+
+**Config Structure (`profile.extraIcons`):**
+
+```lua
+{
+ enabled = true,
+ viewers = {
+ utility = { -- ordered array
+ { stackKey = "trinket1" }, -- resolved from BUILTIN_STACKS
+ { stackKey = "trinket2", disabled = true },
+ { stackKey = "combatPotions" },
+ { kind = "spell", ids = { 59752 } }, -- racial (self-contained)
+ },
+ main = {},
+ },
+}
+```
+
+**Settings UI (`UI/ExtraIconsOptions.lua`):**
+
+Registers through the root/section/page API and exposes only native controls plus the single viewer-management section list. Data helpers (`_addStackKey`, `_removeEntry`, `_reorderEntry`, `_moveEntry`, `_toggleBuiltinRow`, etc.) and page setup hooks (`SetRegisteredPage`, `EnsureItemLoadFrame`, `BuildSections`, `ResetToDefaults`) are exposed on `ns.ExtraIconsOptions` for testability and options bootstrap.
+
+*Row rendering and add flow.* Each viewer renders its ordered rows followed by an inline add row (`[type] [id] [resolved name] [add]`). Draft item IDs resolve asynchronously: pending item loads show `...`, request `GET_ITEM_INFO_RECEIVED`, and refresh the page as soon as Blizzard returns the item data so the resolved name and add button appear without extra typing. Duplicate entries are blocked across both viewers for add and move flows.
+
+*Built-in rows.* Built-in rows use the trailing button as an enable/disable toggle instead of removal, and disabled built-ins are normalized to the bottom of their viewer in `BUILTIN_STACK_ORDER` so they stay visually stable. Missing built-ins are synthesized as disabled placeholders in the utility viewer so older profiles can still re-enable them without a separate quick-add section.
+
+*Trinket and equip-slot filtering.* Trinket built-ins are only rendered when the currently equipped slot resolves to an on-use spell, so passive trinkets stay in saved variables but disappear from the table until they become usable again. Equip-slot placeholders follow the same on-use filter, and trinket-slot equipment changes refresh the page so the visible rows stay in sync.
+
+*Racials.* The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
+
+*Lifecycle.* Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+
+## Actor Diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor User
+ participant ECM as ECM / AceAddon
+ participant Runtime as ns.Runtime
+ participant Extra as ExtraIcons
+ participant FrameProto as BarMixin.FrameProto
+ participant Blizz as Blizzard viewers
+ participant Options as UI/ExtraIconsOptions.lua
+ participant ItemFlow as Item-load frame
+ participant EditMode as EditModeManagerFrame
+
+ rect rgb(26,26,46)
+ ECM->>Extra: OnInitialize()
+ Extra->>FrameProto: AddFrameMixin(self, "ExtraIcons")
+ ECM->>Extra: OnEnable()
+ Extra->>Extra: EnsureFrame() + CreateFrame()
+ Extra->>Runtime: RegisterFrame(self)
+ Extra->>Extra: _rebuildTrackedSlots()
+ Extra->>Extra: Register BAG_UPDATE_*, PLAYER_*, SPELL_* events
+ Extra->>EditMode: Hook OnShow / OnHide after C_Timer.After(0.1)
+ Extra->>Blizz: Hook OnShow / OnHide / OnSizeChanged
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnEnable")
+ end
+
+ rect rgb(26,46,30)
+ Blizz-->>Extra: OnShow
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnShow")
+ Blizz-->>Extra: OnHide
+ Extra->>Extra: Hide viewer container + anchor
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnHide")
+ Blizz-->>Extra: OnSizeChanged
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnSizeChanged")
+ Note over Runtime,Extra: updateAllLayouts() calls ExtraIcons:UpdateLayout() first so GetMainViewerAnchor() already reflects the final main-viewer width before chained bars lay out.
+ end
+
+ rect rgb(46,30,46)
+ User->>Options: Add / remove / move / toggle entry
+ Options->>Options: Mutate profile.extraIcons.viewers
+ Options->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Options->>Options: registeredPage:Refresh()
+ User->>Options: Type draft item ID
+ Options->>ItemFlow: C_Item.RequestLoadItemDataByID(itemId)
+ ItemFlow-->>Options: GET_ITEM_INFO_RECEIVED(itemId)
+ Options->>Options: Clear _pendingItemLoads[itemId]
+ Options->>Options: Refresh page to reveal resolved name / add button
+ end
+
+ rect rgb(30,30,60)
+ User->>Options: Open / close Extra Icons page
+ Options->>Runtime: SetLayoutPreview(true / false)
+ Options->>ItemFlow: Ensure item-load frame
+ EditMode-->>Extra: OnShow
+ Extra->>Extra: Set _isEditModeActive = true and hide containers
+ Extra->>Runtime: RequestLayout("ExtraIcons:EnterEditMode")
+ EditMode-->>Extra: OnHide
+ Extra->>Extra: Set _isEditModeActive = false
+ Extra->>Runtime: RequestLayout("ExtraIcons:ExitEditMode")
+ end
+
+ rect rgb(46,40,26)
+ Blizz-->>Extra: BAG_UPDATE_COOLDOWN
+ Extra->>Extra: ThrottledRefresh("OnBagUpdateCooldown")
+ Blizz-->>Extra: BAG_UPDATE_DELAYED
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnBagUpdateDelayed")
+ Blizz-->>Extra: SPELL_UPDATE_COOLDOWN
+ Extra->>Extra: ThrottledRefresh("OnSpellUpdateCooldown")
+ Blizz-->>Extra: SPELLS_CHANGED
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnSpellsChanged")
+ Blizz-->>Extra: PLAYER_ENTERING_WORLD
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnPlayerEnteringWorld")
+ end
+
+ rect rgb(46,26,30)
+ Blizz-->>Extra: PLAYER_EQUIPMENT_CHANGED(slotId)
+ Extra->>Extra: Check _trackedEquipSlots[slotId]
+ Extra->>Runtime: RequestLayout("ExtraIcons:OnPlayerEquipmentChanged")
+ Blizz-->>ItemFlow: PLAYER_EQUIPMENT_CHANGED(slotId)
+ ItemFlow->>Options: Refresh built-in equip-slot rows / trinket placeholders
+ end
+
+ rect rgb(26,40,46)
+ ECM->>ECM: OnProfileChangedHandler()
+ ECM->>Runtime: Enable(self)
+ ECM->>Runtime: ScheduleLayoutUpdate(0, "ProfileChanged")
+ end
+```
+
+## Component Interaction Diagram
+
+```mermaid
+flowchart TD
+ subgraph BLIZZ["Blizzard viewer frames"]
+ UV["UtilityCooldownViewer"]
+ MV["EssentialCooldownViewer"]
+ end
+
+ subgraph RESOLVE["Resolver inputs"]
+ GIID["GetInventoryItemID"]
+ GIICD["GetInventoryItemCooldown"]
+ GIT["GetInventoryItemTexture"]
+ CITEM["C_Item.*
spell / count / cooldown / icon / name / request load"]
+ CSPELL["C_Spell.*
texture / cooldown / charges"]
+ CSPELLBOOK["C_SpellBook.IsSpellKnown"]
+ URACE["UnitRace"]
+ end
+
+ subgraph ECMI["ECM internals"]
+ RT["Runtime.lua
register + request + first-in-chain layout"]
+ BM["BarMixin.FrameProto"]
+ FU["FrameUtil.lua"]
+ CONST["Constants.lua
BUILTIN_STACKS / BUILTIN_STACK_ORDER / RACIAL_ABILITIES"]
+ DEF["Defaults.lua
profile.extraIcons"]
+ end
+
+ subgraph UI["Settings UI"]
+ OPT["UI/ExtraIconsOptions.lua"]
+ OPTAPI["ns.ExtraIconsOptions helpers
_addStackKey / _removeEntry / _moveEntry / _toggleBuiltinRow / BuildSections"]
+ ITEMLOAD["item-load frame
GET_ITEM_INFO_RECEIVED + tracked PLAYER_EQUIPMENT_CHANGED"]
+ end
+
+ EXTRA["Modules/ExtraIcons.lua
viewer registry + resolver + icon pools"]
+
+ BM -->|inherits lifecycle/config helpers| EXTRA
+ RT -->|RegisterFrame / RequestLayout / ScheduleLayoutUpdate| EXTRA
+ FU -.->|shared lazy layout helpers via runtime + FrameProto| EXTRA
+ CONST -->|stack / racial / viewer constants| EXTRA
+ DEF -->|default profile.extraIcons shape| EXTRA
+
+ UV -->|hook + append utility icons| EXTRA
+ MV -->|hook + extend main anchor footprint| EXTRA
+ EXTRA -->|repositions / sizes| UV
+ EXTRA -->|repositions / sizes| MV
+
+ GIID -->|equip-slot item lookup| EXTRA
+ GIICD -->|slot cooldowns| EXTRA
+ GIT -->|slot textures| EXTRA
+ CITEM -->|item stacks, cooldowns, icons, async names| EXTRA
+ CSPELLBOOK -->|known-spell filter| EXTRA
+ CSPELL -->|spell textures + cooldowns| EXTRA
+ URACE -->|current-player racial id| OPT
+
+ OPT -->|edits profile.extraIcons.viewers| EXTRA
+ OPT -->|exports helpers on| OPTAPI
+ OPT -->|ensures / refreshes| ITEMLOAD
+ ITEMLOAD -->|refresh page when item data or trinkets change| OPT
+ OPT -->|ScheduleLayoutUpdate("OptionsChanged")| RT
+ OPT -->|SetLayoutPreview(true/false)| RT
+
+ style BLIZZ fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style RESOLVE fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style ECMI fill:#1a1a2e,stroke:#a855f7,color:#e0e0e0
+ style UI fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style EXTRA fill:#16213e,stroke:#f43f5e,color:#e0e0e0
+```
+
+## Data Model Class Diagram
+
+```mermaid
+classDiagram
+ class FrameProto {
+ Name
+ InnerFrame
+ ShouldShow()
+ GetModuleConfig()
+ UpdateLayout(why)
+ Refresh(why, force)
+ }
+
+ class ExtraIconsModule {
+ _configKey
+ _viewers
+ _trackedEquipSlots
+ _isEditModeActive
+ _editModeHooked
+ CreateFrame()
+ GetMainViewerAnchor()
+ UpdateLayout(why)
+ Refresh(why, force)
+ }
+
+ class ViewerRuntimeState {
+ anchorFrame
+ container
+ iconPool
+ originalPoint
+ hooked
+ }
+
+ class ECM_Profile {
+ extraIcons
+ }
+
+ class ECM_ExtraIconsConfig {
+ enabled
+ viewers
+ }
+
+ class ECM_ExtraIconsViewers {
+ utility
+ main
+ }
+
+ class ECM_ExtraIconEntry {
+ stackKey
+ kind
+ ids
+ slotId
+ disabled
+ }
+
+ class BuiltinStackEntry {
+ kind
+ slotId
+ ids
+ label
+ }
+
+ class ViewerRegistryEntry {
+ key
+ blizzKey
+ container
+ iconPool
+ hookSet
+ }
+
+ class ExtraIconsOptionsState {
+ _pendingItemLoads
+ _draftStates
+ _itemLoadFrame
+ registeredPage
+ }
+
+ FrameProto <|-- ExtraIconsModule
+ ExtraIconsModule *-- ViewerRuntimeState : owns per viewer
+ ExtraIconsModule *-- ViewerRegistryEntry : builds registry from VIEWERS
+ ECM_Profile *-- ECM_ExtraIconsConfig : extraIcons
+ ECM_ExtraIconsConfig *-- ECM_ExtraIconsViewers : viewers
+ ECM_ExtraIconsViewers *-- ECM_ExtraIconEntry : utility[]
+ ECM_ExtraIconsViewers *-- ECM_ExtraIconEntry : main[]
+ ECM_ExtraIconEntry ..> BuiltinStackEntry : stackKey resolves via BUILTIN_STACKS
+ ExtraIconsModule ..> ECM_ExtraIconsConfig : reads live config
+ ExtraIconsOptionsState ..> ECM_ExtraIconsConfig : edits live config
+```
diff --git a/docs/PowerBar.md b/docs/PowerBar.md
new file mode 100644
index 00000000..e7554f26
--- /dev/null
+++ b/docs/PowerBar.md
@@ -0,0 +1,261 @@
+# PowerBar
+
+## 1. Summary table
+
+| Attribute | Value |
+|---|---|
+| **Module name** | `PowerBar` |
+| **Description** | Renders the player's primary power as a single status bar with optional centered text, per-power colors, and spec-specific value tick marks. It also special-cases mana visibility and treats Elemental Shaman as Maelstrom instead of Mana. |
+| **Source file** | [`Modules/PowerBar.lua`](../Modules/PowerBar.lua) |
+| **Mixin** | `ns.BarMixin.AddBarMixin(self, "PowerBar")` → inherits `BarMixin.BarProto`, which in turn inherits `BarMixin.FrameProto` |
+| **Events listened to** | UNIT_POWER_UPDATE — listens for player power changes; ignores non-player units and calls ns.Runtime.RequestRefresh(self, event) for a throttled values-only refresh.
|
+| **Dependencies** | ns.Addon — AceAddon module creation / lifecycle.ns.Constants — power-type, class/spec, and mana-visibility rules.ns.BarMixin — injects FrameProto + BarProto behavior.ns.Runtime — frame registration and refresh requests.
|
+| **Options file(s)** | [`UI/PowerBarOptions.lua`](../UI/PowerBarOptions.lua), [`UI/PowerBarTickMarksOptions.lua`](../UI/PowerBarTickMarksOptions.lua) |
+| **Options dependencies** | ns.OptionUtil — module toggle handler, shared bar rows, class/spec lookup, color picker, confirm dialog.ns.Runtime — schedules layout updates for tick edits and option changes.ns.Constants / ns.L — constants, slider tiers, localized labels.ns.CloneValue — clones the default tick color for newly added rows.LibSettingsBuilder — consumes the declarative page / row specs exported by these files.
|
+
+## 2. Actor diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game (WoW client)
+ participant ACE as ACE (AceAddon / AceDB / LibEvent)
+ participant ECM as ECM (addon root)
+ participant Runtime as Runtime
+ participant PowerBar as PowerBar
+ participant Deps as Deps (BarMixin / FrameUtil / EditMode / Constants)
+
+ rect rgb(26,26,46)
+ note over Game,Deps: Addon startup / module enable
+ Game->>ACE: ADDON_LOADED → AceAddon dispatch
+ ACE->>PowerBar: OnInitialize()
+ PowerBar->>Deps: BarMixin.AddBarMixin(self, "PowerBar")
+ Game->>ACE: PLAYER_LOGIN → AceAddon dispatch
+ ACE->>ECM: OnEnable()
+ ECM->>Runtime: Enable(addon)
+ Runtime->>ACE: EnableModule("PowerBar")
+ ACE->>PowerBar: OnEnable()
+ PowerBar->>PowerBar: EnsureFrame()
+ PowerBar->>Deps: FrameProto:_RegisterEditMode()
+ PowerBar->>Runtime: RegisterFrame(self)
+ PowerBar->>Game: RegisterEvent("UNIT_POWER_UPDATE")
+ end
+
+ rect rgb(26,46,30)
+ note over Game,Deps: Shared Runtime layout pulse
+ Game->>Runtime: PLAYER_REGEN_* / PLAYER_SPECIALIZATION_CHANGED / ZONE_CHANGED_* / PLAYER_TARGET_CHANGED / etc.
+ Runtime->>Runtime: updateFadeAndHiddenStates()
+ Runtime->>PowerBar: UpdateLayout(reason)
+ PowerBar->>Deps: FrameProto.ApplyFramePosition()
+ PowerBar->>PowerBar: ThrottledRefresh("UpdateLayout(...)" )
+ PowerBar->>PowerBar: Refresh(reason)
+ PowerBar->>Deps: FrameUtil.ApplyFont / LazySet* / tick layout
+ end
+
+ rect rgb(46,30,46)
+ note over Game,Deps: Module event — data-only refresh
+ Game->>PowerBar: UNIT_POWER_UPDATE(unitID)
+ PowerBar->>PowerBar: OnUnitPowerUpdate(); ignore unless unitID == "player"
+ PowerBar->>Runtime: RequestRefresh(self, "UNIT_POWER_UPDATE")
+ Runtime->>PowerBar: ThrottledRefresh(reason)
+ PowerBar->>PowerBar: Refresh(reason)
+ PowerBar->>PowerBar: GetStatusBarValues() / GetStatusBarColor() / GetTickSpec()
+ PowerBar->>Deps: FrameUtil.LazySetStatusBarTexture / Color / value ticks
+ end
+
+ rect rgb(30,30,60)
+ note over Game,Deps: Profile change
+ Game->>ACE: user switches / copies / resets profile
+ ACE->>ECM: OnProfileChangedHandler()
+ ECM->>Runtime: Enable(addon)
+ Runtime->>ACE: EnableModule(PowerBar) when profile.powerBar.enabled is not false
+ ECM->>Runtime: ScheduleLayoutUpdate(0, "ProfileChanged")
+ Runtime->>PowerBar: UpdateLayout("ProfileChanged")
+ PowerBar->>PowerBar: Refresh("ProfileChanged")
+ end
+
+ rect rgb(46,40,26)
+ note over Game,Deps: Edit Mode interactions
+ Game->>Deps: enter / exit / layout switch (LibEditMode)
+ Deps->>Runtime: ScheduleLayoutUpdate(0, "EditModeEnter/Exit/Layout")
+ Runtime->>PowerBar: UpdateLayout(reason)
+ Game->>Deps: drag PowerBar frame
+ Deps->>PowerBar: FrameProto:_SaveEditModePosition(...)
+ Deps->>Runtime: UpdateLayoutImmediately("EditModeDrag")
+ Runtime->>PowerBar: UpdateLayout("EditModeDrag")
+ Game->>Deps: adjust width slider
+ Deps->>PowerBar: cfg.width = value
+ Deps->>Runtime: UpdateLayoutImmediately("EditModeWidth")
+ Runtime->>PowerBar: UpdateLayout("EditModeWidth")
+ end
+
+ rect rgb(46,26,30)
+ note over Game,Deps: Options change
+ Game->>Deps: change PowerBar settings / tick editor rows
+ Deps->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Runtime->>PowerBar: UpdateLayout("OptionsChanged")
+ PowerBar->>PowerBar: Refresh("OptionsChanged")
+ PowerBar->>Deps: re-read profile.powerBar and reapply ticks, colors, text, and layout
+ end
+```
+
+## 3. Component interaction diagram (UML)
+
+```mermaid
+flowchart LR
+ subgraph CALLERS[Inbound callers]
+ Game[Game events]
+ ACE[ACE lifecycle]
+ ECM[ECM addon root]
+ Runtime[ns.Runtime]
+ EditMode[LibEditMode callbacks]
+ Options[PowerBar options pages]
+ end
+
+ subgraph MODULE[PowerBar module]
+ PowerBar[Modules/PowerBar.lua\nPowerBar]
+ end
+
+ subgraph MIXINS[Mixin / frame layer]
+ BarMixin[ns.BarMixin.AddBarMixin]
+ BarProto[BarMixin.BarProto]
+ FrameProto[BarMixin.FrameProto]
+ FrameUtil[ns.FrameUtil]
+ end
+
+ subgraph CONFIG[Config / constants]
+ Profile[profile.powerBar]
+ Constants[ns.Constants]
+ end
+
+ Game -->|dispatches UNIT_POWER_UPDATE| PowerBar
+ ACE -->|calls OnInitialize / OnEnable / OnDisable| PowerBar
+ ECM -->|enables module via Runtime.Enable| PowerBar
+ Runtime -->|calls UpdateLayout(reason)| PowerBar
+ Runtime -->|calls ThrottledRefresh(reason) via RequestRefresh| PowerBar
+ EditMode -->|drag / width changes route through Runtime| Runtime
+ Options -->|mutate config and schedule OptionsChanged| Runtime
+
+ PowerBar -->|mixes in via| BarMixin
+ BarMixin -->|injects| BarProto
+ BarProto -->|extends| FrameProto
+ FrameProto -->|applies anchors / size / border / bg via| FrameUtil
+ BarProto -->|applies text / texture / ticks via| FrameUtil
+
+ PowerBar -->|reads and writes through _configKey = powerBar| Profile
+ PowerBar -->|uses power-type and visibility rules from| Constants
+ PowerBar -->|registers frame with| Runtime
+ PowerBar -->|requests values refresh from| Runtime
+
+ style CALLERS fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style MODULE fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style MIXINS fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style CONFIG fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+```
+
+## 4. Data model class diagram
+
+```mermaid
+classDiagram
+ class ECM_Profile {
+ +number schemaVersion
+ +ECM_PowerBarConfig powerBar
+ }
+
+ class ECM_PowerBarConfig {
+ +boolean enabled
+ +string anchorMode
+ +table editModePositions
+ +number width
+ +number? height
+ +string? texture
+ +boolean overrideFont
+ +string? font
+ +number? fontSize
+ +boolean showText
+ +ECM_Color? bgColor
+ +boolean showManaAsPercent
+ +table colorsByPowerType
+ +ECM_BorderConfig border
+ +ECM_PowerBarTicksConfig ticks
+ }
+
+ class ECM_PowerBarTicksConfig {
+ +table mappingsByClassSpec
+ +ECM_Color defaultColor
+ +number defaultWidth
+ }
+
+ class ECM_TickMark {
+ +number value
+ +ECM_Color? color
+ +number? width
+ }
+
+ class ECM_BorderConfig {
+ +boolean enabled
+ +number thickness
+ +ECM_Color color
+ }
+
+ class ECM_Color {
+ +number r
+ +number g
+ +number b
+ +number a
+ }
+
+ class PowerBarModule {
+ +string Name
+ +Frame InnerFrame
+ +boolean IsHidden
+ -string _configKey
+ -boolean _mixinApplied
+ -number _lastUpdate
+ -Frame _editModeRegisteredFrame
+ -table tickPool
+ +GetTickSpec() table|nil
+ +GetStatusBarValues() number, number, any, boolean
+ +GetStatusBarColor() ECM_Color
+ +ShouldShow() boolean
+ +OnUnitPowerUpdate(event, unitID, ...)
+ +OnInitialize()
+ +OnEnable()
+ +OnDisable()
+ }
+
+ class BarProto {
+ +Refresh(why, force) boolean
+ +EnsureTicks(count, parentFrame, poolKey)
+ +LayoutValueTicks(statusBar, ticks, maxValue, defaultColor, defaultWidth, poolKey)
+ +HideAllTicks(poolKey)
+ }
+
+ class FrameProto {
+ +EnsureFrame()
+ +UpdateLayout(why) boolean
+ +ThrottledRefresh(why) boolean
+ +GetModuleConfig() table|nil
+ +CalculateLayoutParams() table
+ +ApplyFramePosition() table|nil
+ +ShouldShow() boolean
+ }
+
+ class Frame {
+ +Show()
+ +Hide()
+ }
+
+ ECM_Profile *-- ECM_PowerBarConfig : powerBar
+ ECM_PowerBarConfig *-- ECM_BorderConfig : border
+ ECM_PowerBarConfig *-- ECM_PowerBarTicksConfig : ticks
+ ECM_PowerBarConfig *-- "0..*" ECM_Color : colorsByPowerType
+ ECM_PowerBarTicksConfig *-- "0..*" ECM_TickMark : mappings[classID][specIndex]
+ ECM_BorderConfig *-- ECM_Color : color
+ ECM_TickMark o-- ECM_Color : color
+
+ PowerBarModule --|> BarProto
+ BarProto --|> FrameProto
+ PowerBarModule ..> ECM_PowerBarConfig : reads
+ PowerBarModule *-- Frame : InnerFrame
+```
diff --git a/docs/ResourceBar.md b/docs/ResourceBar.md
new file mode 100644
index 00000000..c417ba37
--- /dev/null
+++ b/docs/ResourceBar.md
@@ -0,0 +1,334 @@
+# ResourceBar
+
+`ResourceBar` is the chained status-bar module that renders class/spec-specific secondary resources for Retail WoW. It covers standard combo-style resources like combo points, chi, holy power, essence, and soul shards, plus addon-specific tracked resources such as Vengeance soul fragments, Devourer fragment progress, icicles, and Maelstrom Weapon stacks.
+
+## 1. Summary table
+
+| Attribute | Value |
+|---|---|
+| **Module name** | `ResourceBar` |
+| **Description** | Renders a single status bar for the player's current class/spec resource, switching resource type dynamically through `ClassUtil.GetPlayerResourceType()`. It also draws divider ticks for discrete resources and supports alternate capped colors for selected resource types. |
+| **Source file** | [`Modules/ResourceBar.lua`](../Modules/ResourceBar.lua) |
+| **Mixin** | `BarMixin.AddBarMixin(self, "ResourceBar")` — inherits `BarProto`, which inherits `FrameProto`. `ResourceBar` overrides `ShouldShow()`, `GetStatusBarValues()`, `GetStatusBarColor()`, and `GetTickSpec()` on top of the shared bar/frame lifecycle. |
+| **Events listened to** | - `UNIT_AURA` — player-only refresh path for aura-backed resources such as icicles, soul fragments, Devourer progress, and Maelstrom Weapon; secret-value-bearing.
- `UNIT_POWER_UPDATE` — player-only refresh path for standard power resources and any resource changes surfaced through the power event; secret-value-bearing.
|
+| **Dependencies** | - `ns.Addon` — owns the Ace module instance via `:NewModule()`.
- `ns.BarMixin` — supplies `BarProto`/`FrameProto` behavior.
- `ns.Runtime` — frame registration plus values-only refresh dispatch.
- `ns.ClassUtil` — resolves active resource type and `(max, current, safeMax)` values.
- `ns.Constants` — resource-type IDs, tick color, and capped-color feature gates.
|
+| **Options file(s)** | [`UI/ResourceBarOptions.lua`](../UI/ResourceBarOptions.lua) |
+| **Options dependencies** | - `ns.OptionUtil` — module enabled handler, disabled delegate, and shared bar row generation.
- `ns.Constants` — resource-type IDs, class colors, and max-color eligibility.
- `ns.L` — localized labels/tooltips.
- `LibSettingsBuilder` row schema — the page spec returned here is consumed by the root options registration in `UI/Options.lua`.
|
+
+## 2. Actor diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game (WoW client)
+ participant ACE as ACE (AceAddon / AceDB / CallbackHandler)
+ participant ECM as ECM (addon root)
+ participant Runtime as Runtime
+ participant RB as ResourceBar
+ participant Deps as Deps (BarMixin, ClassUtil,
FrameUtil, LibEditMode, Constants)
+
+ rect rgb(26,26,46)
+ note over Game,Deps: Addon startup and first render
+ Game->>ACE: ADDON_LOADED / module load
+ ACE->>RB: OnInitialize()
+ RB->>Deps: BarMixin.AddBarMixin(self, "ResourceBar")
+ Game->>ACE: PLAYER_LOGIN
+ ACE->>ECM: OnEnable()
+ ECM->>Runtime: Runtime.Enable(addon)
+ Runtime->>ACE: EnableModule("ResourceBar")
+ ACE->>RB: OnEnable()
+ RB->>RB: EnsureFrame()
+ RB->>Runtime: RegisterFrame(self)
+ RB->>Game: RegisterEvent(UNIT_AURA)
+ RB->>Game: RegisterEvent(UNIT_POWER_UPDATE)
+ Runtime->>RB: UpdateLayout("ModuleInit")
+ RB->>Deps: FrameProto.ApplyFramePosition() / FrameUtil lazy setters
+ RB->>RB: ThrottledRefresh("UpdateLayout")
+ RB->>Deps: ClassUtil.GetPlayerResourceType()
+ RB->>Deps: ClassUtil.GetCurrentMaxResourceValues()
+ RB->>Deps: FrameUtil texture/color updates
+ RB->>RB: GetTickSpec() -> EnsureTicks() -> LayoutResourceTicks()
+ end
+
+ rect rgb(26,46,30)
+ note over Game,Deps: Shared runtime layout pulse reaches ResourceBar
+ Game->>Runtime: layout event (mount/combat/zone/spec/target/edit-preview state)
+ Runtime->>Runtime: updateFadeAndHiddenStates()
+ Runtime->>RB: UpdateLayout(reason)
+ RB->>Deps: FrameProto.ApplyFramePosition() / background / border
+ RB->>RB: ThrottledRefresh(reason)
+ RB->>Deps: GetStatusBarValues() / GetStatusBarColor() / GetTickSpec()
+ RB->>Deps: EnsureTicks() / LayoutResourceTicks()
+ end
+
+ rect rgb(46,30,46)
+ note over Game,Deps: Module data event — UNIT_POWER_UPDATE
+ Game->>RB: UNIT_POWER_UPDATE(unit = "player", ...)
+ RB->>RB: OnEventUpdate(event, unit)
+ RB->>Runtime: RequestRefresh(self, "UNIT_POWER_UPDATE")
+ Runtime->>RB: ThrottledRefresh("UNIT_POWER_UPDATE")
+ RB->>Deps: ClassUtil.GetPlayerResourceType()
+ RB->>Deps: ClassUtil.GetCurrentMaxResourceValues()
(secret-bearing power path)
+ RB->>RB: GetStatusBarColor() / GetTickSpec()
+ end
+
+ rect rgb(30,30,60)
+ note over Game,Deps: Module data event — UNIT_AURA
+ Game->>RB: UNIT_AURA(unit = "player", ...)
+ RB->>RB: OnEventUpdate(event, unit)
+ RB->>Runtime: RequestRefresh(self, "UNIT_AURA")
+ Runtime->>RB: ThrottledRefresh("UNIT_AURA")
+ RB->>Deps: ClassUtil.GetPlayerResourceType()
+ RB->>Deps: ClassUtil.GetCurrentMaxResourceValues()
(secret-bearing aura path)
+ RB->>RB: GetStatusBarColor() / GetTickSpec()
+ end
+
+ rect rgb(46,40,26)
+ note over Game,Deps: Profile change
+ Game->>ACE: profile switched / copied / reset
+ ACE->>ECM: OnProfileChangedHandler()
+ ECM->>Runtime: Runtime.Enable(addon)
+ ECM->>Runtime: ScheduleLayoutUpdate(0, "ProfileChanged")
+ Runtime->>RB: UpdateLayout("ProfileChanged")
+ RB->>Deps: Re-read live config from AceDB
+ RB->>RB: Refresh with new width/colors/tick behavior
+ end
+
+ rect rgb(46,26,30)
+ note over Game,Deps: Options change
+ Game->>Deps: Settings row changed in ResourceBar options
+ Deps->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Runtime->>RB: UpdateLayout("OptionsChanged")
+ RB->>Deps: GetModuleConfig() / GetGlobalConfig()
+ RB->>RB: Refresh with updated appearance/config
+ end
+
+ rect rgb(26,40,46)
+ note over Game,Deps: Edit Mode enter / exit
+ Game->>Deps: LibEditMode enter or exit callback
+ Deps->>Runtime: ScheduleLayoutUpdate(0, "EditModeEnter" / "EditModeExit")
+ Runtime->>RB: UpdateLayout(reason)
+ RB->>Deps: FrameProto.ApplyFramePosition()
+ RB->>RB: Refresh while force-visible
+ end
+
+ rect rgb(36,26,46)
+ note over Game,Deps: Edit Mode drag / width slider
+ Game->>Deps: drag frame or change width slider
+ Deps->>Runtime: UpdateLayoutImmediately("EditModeDrag" / "EditModeWidth")
+ Runtime->>RB: UpdateLayout(reason)
+ RB->>Deps: ApplyFramePosition() with saved Edit Mode position
+ RB->>RB: Refresh and recompute ticks against new width
+ end
+```
+
+## 3. Component interaction diagram (UML)
+
+```mermaid
+flowchart LR
+ subgraph IN[Inbound callers]
+ ACE[ACE / ECM lifecycle
OnInitialize, OnEnable, OnDisable]
+ GAME[WoW events
UNIT_POWER_UPDATE, UNIT_AURA]
+ RT[Runtime
UpdateLayout, RequestRefresh]
+ UX[Options, profile, Edit Mode
via Runtime scheduling]
+ end
+
+ subgraph CORE[ResourceBar module]
+ RB[ResourceBar
Modules/ResourceBar.lua]
+ end
+
+ subgraph DIRECT[Direct module dependencies]
+ ADDON[ns.Addon
Ace module owner]
+ BM[BarMixin.AddBarMixin
BarProto + FrameProto]
+ CU[ClassUtil
resource type and values]
+ CONST[Constants
resource IDs and gates]
+ RTA[Runtime API
RegisterFrame / RequestRefresh]
+ end
+
+ subgraph INHERITED[Inherited services used during layout/refresh]
+ FP[FrameProto
EnsureFrame, UpdateLayout,
ApplyFramePosition, GetModuleConfig]
+ BP[BarProto
Refresh, EnsureTicks,
LayoutResourceTicks]
+ FU[FrameUtil
anchors, alpha, texture,
status-bar color]
+ EM[EditMode / LibEditMode
saved positions and width slider]
+ DB[AceDB profile
resourceBar + global]
+ end
+
+ ACE -->|constructs / enables / disables| RB
+ GAME -->|dispatches player-only module events| RB
+ RT -->|calls layout and values refresh paths| RB
+ UX -->|schedules runtime pulses that reach| RT
+
+ RB -->|created by| ADDON
+ RB -->|mixes in| BM
+ RB -->|queries active resource type and values| CU
+ RB -->|reads resource constants and color gates| CONST
+ RB -->|registers with / asks for refresh from| RTA
+
+ RB -->|inherits geometry / visibility logic from| FP
+ RB -->|inherits status-bar + tick logic from| BP
+ FP -->|applies lazy frame mutations through| FU
+ FP -->|reads live config from| DB
+ FP -->|registers frame settings with| EM
+ BP -->|uses setters and pixel snapping from| FU
+
+ style ACE fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style GAME fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style RT fill:#16213e,stroke:#7a84f7,color:#e0e0e0
+ style UX fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0
+ style RB fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style ADDON fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style BM fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style CU fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style CONST fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style RTA fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0
+ style FP fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style BP fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style FU fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style EM fill:#1a1a2e,stroke:#a855f7,color:#e0e0e0
+ style DB fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+```
+
+## 4. Data model class diagram
+
+`Defaults.lua` seeds `resourceBar` with `enabled = true`, `showText = false`, `anchorMode = "chain"`, `width = 300`, empty `editModePositions`, a disabled border block, resource color tables, and max-color tables for the resource types gated in `Constants.lua`.
+
+```mermaid
+classDiagram
+ class ResourceBarModule {
+ +Name: string
+ +InnerFrame: Frame
+ +IsHidden: boolean
+ -_configKey: string
+ -_mixinApplied: boolean
+ -_lastUpdate: number
+ -_editModeRegisteredFrame: Frame
+ +tickPool: Texture[]
+ +ShouldShow() boolean
+ +GetStatusBarValues() tuple
+ +GetStatusBarColor() ECM_Color
+ +GetTickSpec() ECM_ResourceTickSpec
+ +OnEventUpdate(event, unit)
+ +OnInitialize()
+ +OnEnable()
+ +OnDisable()
+ }
+
+ class FrameProto {
+ +EnsureFrame()
+ +UpdateLayout(why) boolean
+ +ApplyFramePosition() table
+ +GetModuleConfig() ECM_ResourceBarConfig
+ +SetHidden(hide)
+ +ThrottledRefresh(why) boolean
+ }
+
+ class BarProto {
+ +Refresh(why, force) boolean
+ +EnsureTicks(count, parentFrame, poolKey)
+ +HideAllTicks(poolKey)
+ +LayoutResourceTicks(maxResources, color, width, poolKey)
+ +GetStatusBarValues()
+ +GetStatusBarColor() ECM_Color
+ }
+
+ class ECM_Profile {
+ +resourceBar: ECM_ResourceBarConfig
+ +global: ECM_GlobalConfig
+ }
+
+ class ECM_BarConfigBase {
+ +enabled: boolean
+ +editModePositions: map
+ +width: number
+ +height: number?
+ +texture: string?
+ +overrideFont: boolean
+ +font: string?
+ +fontSize: number?
+ +showText: boolean?
+ +bgColor: ECM_Color?
+ +anchorMode: string
+ }
+
+ class ECM_ResourceBarConfig {
+ +colors: map
+ +maxColors: map
+ +maxColorsEnabled: map
+ +border: ECM_BorderConfig
+ }
+
+ class ECM_BorderConfig {
+ +enabled: boolean
+ +thickness: number
+ +color: ECM_Color
+ }
+
+ class ECM_Color {
+ +r: number
+ +g: number
+ +b: number
+ +a: number
+ }
+
+ class ECM_ResourceTickSpec {
+ +maxResources: number
+ +color: ECM_Color
+ +width: number
+ }
+
+ class ECM_GlobalConfig {
+ +barHeight: number
+ +barBgColor: ECM_Color
+ +texture: string
+ +updateFrequency: number
+ +moduleGrowDirection: string
+ +detachedBarWidth: number
+ }
+
+ class ECM_ResourceType {
+ <>
+ ComboPoints
+ Chi
+ HolyPower
+ Essence
+ SoulShards
+ ArcaneCharges
+ souls
+ devourerNormal
+ devourerMeta
+ icicles
+ maelstromWeapon
+ }
+
+ class ClassUtil {
+ +GetPlayerResourceType() ECM_ResourceType
+ +GetCurrentMaxResourceValues(resourceType) tuple
+ }
+
+ ResourceBarModule --|> BarProto
+ BarProto --|> FrameProto
+ ECM_ResourceBarConfig --|> ECM_BarConfigBase
+ ECM_Profile *-- ECM_ResourceBarConfig : resourceBar
+ ECM_Profile *-- ECM_GlobalConfig : global
+ ECM_ResourceBarConfig *-- ECM_BorderConfig : border
+ ECM_BorderConfig *-- ECM_Color : color
+ ECM_ResourceTickSpec *-- ECM_Color : color
+ ResourceBarModule ..> ECM_ResourceTickSpec : returns
+ ResourceBarModule ..> ECM_ResourceBarConfig : GetModuleConfig()
+ ResourceBarModule ..> ECM_GlobalConfig : GetGlobalConfig()
+ ResourceBarModule ..> ECM_ResourceType : active type
+ ResourceBarModule ..> ClassUtil : queries type/max/current
+
+ style ResourceBarModule fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+ style FrameProto fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style BarProto fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style ECM_Profile fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style ECM_BarConfigBase fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style ECM_ResourceBarConfig fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style ECM_BorderConfig fill:#1a1a2e,stroke:#a855f7,color:#e0e0e0
+ style ECM_Color fill:#1a1a2e,stroke:#a855f7,color:#e0e0e0
+ style ECM_ResourceTickSpec fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0
+ style ECM_GlobalConfig fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style ECM_ResourceType fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style ClassUtil fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+```
diff --git a/docs/RuneBar.md b/docs/RuneBar.md
new file mode 100644
index 00000000..46381f8e
--- /dev/null
+++ b/docs/RuneBar.md
@@ -0,0 +1,292 @@
+# RuneBar
+
+`RuneBar` is ECM's Death Knight rune display module. It is the third chained bar module after `PowerBar` and `ResourceBar`, and it participates in the shared `Runtime.lua` layout, fade, profile, options, and Edit Mode flows while owning its rune-specific fragment rendering and cooldown animation ticker.
+
+## Summary
+
+| Item | Details |
+|---|---|
+| **Module name** | `RuneBar` |
+| **Description** | Displays the six Death Knight runes as a fragmented bar. Ready runes are packed first, cooling runes follow in remaining-time order, and rune cooldown fill animates on a lightweight ticker while any rune is recharging. |
+| **Source file** | [`Modules/RuneBar.lua`](../Modules/RuneBar.lua) |
+| **Mixin** | `BarMixin.AddBarMixin(self, "RuneBar")` → `BarMixin.BarProto` layered over `BarMixin.FrameProto`; `RuneBar` overrides `CreateFrame()`, `ShouldShow()`, and `Refresh()`. |
+| **Events listened to** | - `RUNE_POWER_UPDATE` — the only WoW event registered directly by `RuneBar`; starts the value ticker and requests a throttled refresh.
- Shared layout pulses come from [`Runtime.lua`](../Runtime.lua), which calls `RuneBar:UpdateLayout(...)` on global lifecycle events rather than having `RuneBar` register them itself. |
+| **Dependencies** | - [`BarMixin.lua`](../BarMixin.lua) — shared frame/bar mixins, tick pools, Edit Mode frame registration.
- [`Runtime.lua`](../Runtime.lua) — module registration, shared layout execution, shared fade/hidden state, refresh requests.
- [`FrameUtil.lua`](../FrameUtil.lua) — texture lookup, pixel snapping, lazy frame setters used indirectly through layout helpers and directly for fragment sizing.
- [`Constants.lua`](../Constants.lua) — `RUNEBAR_MAX_RUNES`, `RUNEBAR_CD_DIM_FACTOR`, Death Knight spec constants, chain order, shared timing defaults.
- [`ECM.lua`](../ECM.lua) — `ns.IsDeathKnight()`, `ns.GetGlobalConfig()`, addon module lifecycle.
- WoW APIs — `GetRuneCooldown()`, `GetSpecialization()`, `GetTime()`, `C_Timer.NewTicker()`, `CreateFrame()`. |
+| **Options file(s)** | [`UI/RuneBarOptions.lua`](../UI/RuneBarOptions.lua) |
+| **Options dependencies** | - `ns.OptionUtil` — standard bar rows, module enable handler, disabled delegates.
- `ns.Addon.db.profile.runeBar` — live config reads/writes for checkbox and color rows.
- `ns.L` — localized labels/tooltips.
- `ns.IsDeathKnight()` — DK-only gating for warning text and page disabled state.
- [`UI/Options.lua`](../UI/Options.lua) / `LibSettingsBuilder-1.0` — consumes `ns.RuneBarOptions` as one section in the root settings tree. |
+
+## Actor diagram
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Game as Game
+ participant ACE as ACE / AceAddon / AceDB
+ participant ECM as ECM
+ participant Runtime as Runtime
+ participant RuneBar as RuneBar
+ participant Mixins as BarMixin / FrameProto
+ participant UI as Options / Edit Mode
+
+ rect rgb(26,26,46)
+ note over Game,UI: Startup / initialization
+ Game->>ACE: ADDON_LOADED / PLAYER_LOGIN
+ ACE->>RuneBar: OnInitialize()
+ RuneBar->>Mixins: AddBarMixin(self, "RuneBar")
+ ACE->>ECM: OnEnable()
+ ECM->>Runtime: Enable(addon)
+ Runtime->>RuneBar: EnableModule("RuneBar")
+ RuneBar->>RuneBar: OnEnable()
+ RuneBar->>Mixins: EnsureFrame()
+ RuneBar->>Runtime: RegisterFrame(self)
+ RuneBar->>Game: RegisterEvent("RUNE_POWER_UPDATE")
+ Game->>Runtime: Shared layout event
+ Runtime->>RuneBar: UpdateLayout(reason)
+ RuneBar->>Mixins: ApplyFramePosition() / ThrottledRefresh()
+ RuneBar->>RuneBar: Refresh()
ensure fragments + ticks
updateFragmentedRuneDisplay()
+ end
+
+ rect rgb(26,46,30)
+ note over Game,UI: Rune event entry point
+ Game->>RuneBar: RUNE_POWER_UPDATE
+ RuneBar->>RuneBar: _StartAnimationTicker()
+ RuneBar->>Runtime: RequestRefresh("RuneBar:RUNE_POWER_UPDATE")
+ Runtime->>RuneBar: ThrottledRefresh(...)
+ RuneBar->>RuneBar: Refresh()
+ end
+
+ rect rgb(46,30,46)
+ note over Game,UI: Rune cooldown animation ticker
+ Game->>RuneBar: C_Timer.NewTicker(updateFrequency)
+ RuneBar->>RuneBar: updateRuneValues(self, InnerFrame)
+ alt ready/cooldown membership changed
+ RuneBar->>Runtime: RequestRefresh("RuneBar:RuneStateChange")
+ Runtime->>RuneBar: ThrottledRefresh(...)
+ RuneBar->>RuneBar: Refresh()
reorder + reposition fragments
+ else only fill values changed
+ RuneBar->>RuneBar: applyRuneFragmentVisual() per fragment
+ end
+ alt all runes ready
+ RuneBar->>RuneBar: _StopAnimationTicker()
+ end
+ end
+
+ rect rgb(30,30,60)
+ note over Game,UI: Runtime layout pulses
+ Game->>Runtime: Mount / vehicle / combat / zone / spec / target / resting / CVAR events
+ Runtime->>Runtime: updateFadeAndHiddenStates()
+ Runtime->>RuneBar: UpdateLayout(reason)
+ RuneBar->>Mixins: ApplyFramePosition()
+ RuneBar->>RuneBar: Refresh()
LayoutResourceTicks(6)
start ticker if any rune cooling down
+ end
+
+ rect rgb(46,40,26)
+ note over Game,UI: Profile change
+ Game->>ACE: profile changed / copied / reset
+ ACE->>ECM: OnProfileChangedHandler()
+ ECM->>Runtime: Enable(addon)
+ ECM->>Runtime: ScheduleLayoutUpdate(0, "ProfileChanged")
+ Runtime->>RuneBar: UpdateLayout("ProfileChanged")
+ end
+
+ rect rgb(46,26,30)
+ note over Game,UI: Options change
+ Game->>UI: User changes RuneBar setting
+ UI->>Runtime: ScheduleLayoutUpdate(0, "OptionsChanged")
+ Runtime->>RuneBar: UpdateLayout("OptionsChanged")
+ end
+
+ rect rgb(26,40,46)
+ note over Game,UI: Edit Mode
+ Game->>UI: Edit Mode enter / exit / layout switch
+ UI->>Runtime: ScheduleLayoutUpdate(0, "EditModeEnter/Exit/Layout")
+ Runtime->>RuneBar: UpdateLayout(...)
+ Game->>UI: Drag RuneBar frame or adjust width
+ UI->>Runtime: UpdateLayoutImmediately("EditModeDrag" / "EditModeWidth")
+ Runtime->>RuneBar: UpdateLayout(...)
+ end
+```
+
+## Component interaction diagram
+
+```mermaid
+flowchart TD
+ Game["Game / WoW APIs"] -->|`RUNE_POWER_UPDATE`| RuneBar
+ ACE["AceAddon / AceDB"] -->|`OnInitialize` / `OnEnable` / profile callbacks| RuneBar
+ ACE -->|profile change re-enable path| Runtime
+ UI["Options UI / LibSettingsBuilder / LibEditMode"] -->|config writes / drag-resize callbacks| Runtime
+ Runtime -->|`EnableModule`| RuneBar
+ Runtime -->|`RegisterFrame` stores module| RuneBar
+ Runtime -->|`UpdateLayout(reason)`| RuneBar
+ Runtime -->|`RequestRefresh(...)` -> `ThrottledRefresh(...)`| RuneBar
+ RuneBar -->|`RequestRefresh`| Runtime
+ RuneBar -->|`RegisterFrame` / `UnregisterFrame`| Runtime
+
+ subgraph MIXINS["Shared mixins"]
+ FrameProto["`BarMixin.FrameProto`
+positioning / visibility / Edit Mode"]
+ BarProto["`BarMixin.BarProto`
+StatusBar / ticks / throttled refresh"]
+ FrameProto --> BarProto
+ end
+
+ subgraph DATA["Shared addon state"]
+ ECM["`ECM.lua`
+`ns.IsDeathKnight()`
+`ns.GetGlobalConfig()`"]
+ Constants["`Constants.lua`
+chain order / rune constants"]
+ Defaults["`Defaults.lua`
+`profile.runeBar` defaults"]
+ end
+
+ subgraph UTIL["Utility helpers"]
+ FrameUtil["`FrameUtil.lua`
+`GetTexture()`
+`PixelSnap()`
+lazy setters"]
+ OptionUtil["`OptionUtil`
+shared option rows / delegates"]
+ end
+
+ subgraph OPTIONS["Settings pages"]
+ RuneBarOptions["`UI/RuneBarOptions.lua`
+page spec"]
+ RootOptions["`UI/Options.lua`
+root registration"]
+ RuneBarOptions -->|section export| RootOptions
+ end
+
+ subgraph RUNTIME_STATE["RuneBar-owned runtime state"]
+ Frame["InnerFrame
+StatusBar + TicksFrame + FragmentedBars"]
+ Ticker["`_valueTicker`
+active while any rune is cooling down"]
+ Ready["`_readySet` / `_lastReadySet`
+ready membership cache"]
+ Cooldowns["`_cdLookup`
+remaining + frac by rune"]
+ Order["`_displayOrder` / `_cdSortBuf`
+layout order cache"]
+ end
+
+ RuneBar -->|mixin methods| BarProto
+ RuneBar -->|module/global config lookup| ECM
+ RuneBar -->|rune count, dim factor, spec keys| Constants
+ RuneBar -->|default persisted config| Defaults
+ RuneBar -->|frame creation / texture / pixel snapping| FrameUtil
+ RuneBar -->|event + timer APIs| Game
+ RuneBar -->|options read/write target| RuneBarOptions
+ RuneBar --> Frame
+ RuneBar --> Ticker
+ Frame --> Ready
+ Frame --> Cooldowns
+ Frame --> Order
+
+ style MIXINS fill:#1a1a2e,stroke:#7a84f7,color:#e0e0e0
+ style DATA fill:#1a1a2e,stroke:#22c55e,color:#e0e0e0
+ style UTIL fill:#1a1a2e,stroke:#f7a855,color:#e0e0e0
+ style OPTIONS fill:#1a1a2e,stroke:#f43f5e,color:#e0e0e0
+ style RUNTIME_STATE fill:#1a1a2e,stroke:#4cc9f0,color:#e0e0e0
+```
+
+## Data model class diagram
+
+```mermaid
+classDiagram
+ class FrameProto {
+ +Name
+ +InnerFrame
+ +IsHidden
+ +EnsureFrame()
+ +UpdateLayout(why)
+ +ThrottledRefresh(why)
+ +ApplyFramePosition()
+ +GetModuleConfig()
+ }
+
+ class BarProto {
+ +EnsureTicks(count,parentFrame,poolKey)
+ +HideAllTicks(poolKey)
+ +LayoutResourceTicks(maxResources,color,width,poolKey)
+ +Refresh(why, force)
+ }
+
+ class RuneBar {
+ +Name
+ +_configKey
+ +_mixinApplied
+ +_lastUpdate
+ +_valueTicker
+ +CreateFrame()
+ +ShouldShow()
+ +Refresh(why, force)
+ +OnEnable()
+ +OnRunePowerUpdate()
+ +OnDisable()
+ +_StartAnimationTicker()
+ +_StopAnimationTicker()
+ }
+
+ class RuneBarFrame {
+ +StatusBar
+ +TicksFrame
+ +FragmentedBars
+ +_maxResources
+ +_readySet
+ +_cdLookup
+ +_lastReadySet
+ +_displayOrder
+ +_cdSortBuf
+ +_lastBarWidth
+ +_lastBarHeight
+ +_lastValueUpdate
+ }
+
+ class RuneCooldownState {
+ +remaining
+ +frac
+ }
+
+ class ECM_RuneBarConfig {
+ +enabled
+ +anchorMode
+ +width
+ +editModePositions
+ +overrideFont
+ +useSpecColor
+ +color
+ +colorBlood
+ +colorFrost
+ +colorUnholy
+ }
+
+ class ECM_GlobalConfig {
+ +updateFrequency
+ +barHeight
+ +barBgColor
+ +moduleSpacing
+ +moduleGrowDirection
+ +texture
+ +font
+ +fontSize
+ +fontOutline
+ +fontShadow
+ +detachedBarWidth
+ +detachedModuleSpacing
+ +detachedGrowDirection
+ }
+
+ FrameProto <|-- BarProto
+ BarProto <|-- RuneBar
+ RuneBar *-- RuneBarFrame : owns `InnerFrame`
+ RuneBarFrame *-- "0..6" RuneCooldownState : `_cdLookup`
+ RuneBar --> ECM_RuneBarConfig : `GetModuleConfig()`
+ RuneBar --> ECM_GlobalConfig : `GetGlobalConfig()`
+```
+
+## Notes
+
+- `RuneBar` does **not** call into [`BarStyle.lua`](../BarStyle.lua); that shared styling namespace is for `BuffBars` / `ExternalBars`. RuneBar styling is bar-native through `BarMixin` + `FrameUtil`.
+- `ClassUtil.lua` and `ColorUtil.lua` do not participate directly in RuneBar runtime logic; RuneBar uses Death Knight class gating from [`ECM.lua`](../ECM.lua) and its own rune/spec color selection.
+- Config in the class diagram is verified against [`Defaults.lua`](../Defaults.lua). Registered events are verified against explicit `RegisterEvent(...)` calls in [`Modules/RuneBar.lua`](../Modules/RuneBar.lua) and shared runtime registration in [`Runtime.lua`](../Runtime.lua).
From 2a2a294fcf917b1e7f18e4088b4ea94cc6deca5b Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sun, 26 Apr 2026 02:10:06 +1000
Subject: [PATCH 34/53] fix spell cache bug.
Co-authored-by: Copilot
---
AGENTS.md | 1 +
Constants.lua | 6 -
.../Controls/CollectionFrames.lua | 187 +++++++++++++++---
Tests/UI/BuffBarsOptions_spec.lua | 76 ++++++-
Tests/UI/ExtraIconsOptions_spec.lua | 27 ++-
Tests/UI/Options_spec.lua | 31 +++
UI/ExtraIconsOptions.lua | 90 +++++----
UI/OptionUtil.lua | 7 +-
UI/PowerBarTickMarksOptions.lua | 2 +-
UI/SpellColorsPage.lua | 62 +++---
docs/ExtraIcons.md | 2 +-
11 files changed, 380 insertions(+), 111 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9a005d33..94ede618 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -94,6 +94,7 @@ All Lua files start with:
- Libraries stay self-contained: no ECM internals; tests and docs live with the library; public API changes are intentional and documented.
- Frame templates must be defined in `.xml`, not via Lua hooks on Blizzard functions like `Settings.CreateElementInitializer`. XML virtual templates with `mixin="GlobalMixinName"` are inherently multi-addon safe via LibStub.
- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code.
+- A single style/metric must have a single owner. If a library renders a widget, the library owns its dimensions, padding, fonts, and colors — callers must not redeclare those values, even via "override" knobs that happen to match the default. If every caller would pass the same value, delete the knob and bake it into the library. Override hooks are only justified when callers genuinely need different values.
---
diff --git a/Constants.lua b/Constants.lua
index 5c635087..5ee91359 100644
--- a/Constants.lua
+++ b/Constants.lua
@@ -176,12 +176,6 @@ local constants = {
-- UI dimension constants
POSITION_MODE_EXPLAINER_HEIGHT = 150,
- SCROLL_ROW_HEIGHT_COMPACT = 26,
- SCROLL_ROW_HEIGHT_WITH_CONTROLS = 34,
- SPELL_COLORS_SCROLL_BOTTOM_OFFSET_WITH_SECRET_NAMES = 80,
- SPELL_COLORS_SECRET_NAMES_BUTTON_BOTTOM_OFFSET = 8,
- SPELL_COLORS_SECRET_NAMES_DESC_BOTTOM_OFFSET = 42,
- SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT = 40,
VALUE_SLIDER_TIERS = {
{ ceiling = 200, step = 1 },
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index b5c3f61f..d36ad9d1 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -12,6 +12,7 @@ local ADD = _G.ADD
local REMOVE = _G.REMOVE
local internal = lib._internal
+local SECTION_HEADER_HEIGHT = 50
local applyActionButtonTextures = internal.applyActionButtonTextures
local configureInlineSlider = internal.configureInlineSlider
local evaluateStaticOrFunction = internal.evaluateStaticOrFunction
@@ -20,11 +21,20 @@ local setSimpleTooltip = internal.setSimpleTooltip
local setTextureValue = internal.setTextureValue
local showFrame = internal.showFrame
+local DISABLED_ROW_ALPHA = 0.5
+
local function applyCollectionRowStyle(row, item)
- local alpha = item and item.alpha or 1
+ local disabled = item and item.disabled == true
+ local alpha = item and item.alpha or (disabled and DISABLED_ROW_ALPHA or 1)
+ local labelFontObject = item and item.labelFontObject
+ or (disabled and (_G.GameFontDisable or _G.GameFontNormal) or nil)
+ local iconDesaturated = item and item.iconDesaturated
+ if iconDesaturated == nil then
+ iconDesaturated = disabled
+ end
- if row._label and row._label.SetFontObject and item and item.labelFontObject then
- row._label:SetFontObject(item.labelFontObject)
+ if row._label and row._label.SetFontObject and labelFontObject then
+ row._label:SetFontObject(labelFontObject)
end
if row._label and row._label.SetTextColor and item and item.labelColor then
row._label:SetTextColor(
@@ -41,7 +51,7 @@ local function applyCollectionRowStyle(row, item)
row._icon:SetAlpha(alpha)
end
if row._icon and row._icon.SetDesaturated then
- row._icon:SetDesaturated(item and item.iconDesaturated == true or false)
+ row._icon:SetDesaturated(iconDesaturated == true)
end
if row._icon and row._icon.SetVertexColor then
local color = item and item.iconVertexColor
@@ -58,12 +68,20 @@ local function bindCollectionRowTooltip(row, item)
return
end
+ local label = row._label
if row.EnableMouse then
row:EnableMouse(item ~= nil)
end
row:SetScript("OnEnter", nil)
row:SetScript("OnLeave", nil)
+ if label and label.SetScript then
+ label:SetScript("OnEnter", nil)
+ label:SetScript("OnLeave", nil)
+ end
+ if label and label.EnableMouse then
+ label:EnableMouse(false)
+ end
if not item then
return
@@ -73,23 +91,33 @@ local function bindCollectionRowTooltip(row, item)
if self._highlight and self._highlight.Show then
self._highlight:Show()
end
- if item.onEnter then
- item.onEnter(self, item)
- elseif item.tooltip then
- if GameTooltip then
- GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
- if GameTooltip.ClearLines then
- GameTooltip:ClearLines()
- end
- setGameTooltipText(item.tooltip, true)
- GameTooltip:Show()
- end
- end
end)
row:SetScript("OnLeave", function(self)
if self._highlight and self._highlight.Hide then
self._highlight:Hide()
end
+ end)
+
+ if not label or not label.SetScript or (not item.onEnter and not item.tooltip) then
+ return
+ end
+
+ if label.EnableMouse then
+ label:EnableMouse(true)
+ end
+ label:SetScript("OnEnter", function(self)
+ if item.onEnter then
+ item.onEnter(self, item)
+ elseif GameTooltip then
+ GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
+ if GameTooltip.ClearLines then
+ GameTooltip:ClearLines()
+ end
+ setGameTooltipText(item.tooltip, true)
+ GameTooltip:Show()
+ end
+ end)
+ label:SetScript("OnLeave", function(self)
if item.onLeave then
item.onLeave(self, item)
elseif GameTooltip_Hide then
@@ -167,10 +195,9 @@ local function refreshSwatchCollectionRow(row, item)
end
end)
if row._swatch.SetEnabled then
- row._swatch:SetEnabled(
- evaluateStaticOrFunction(item.enabled, item, row) ~= false
- and evaluateStaticOrFunction(color.enabled, item, row) ~= false
- )
+ local enabled = evaluateStaticOrFunction(item.enabled, item, row) ~= false
+ and evaluateStaticOrFunction(color.enabled, item, row) ~= false
+ row._swatch:SetEnabled(enabled)
end
end
@@ -320,6 +347,96 @@ end
local ACTION_BUTTON_ORDER = { "up", "down", "move", "delete" }
local ACTION_BUTTON_SPACING = 2
+local DISABLED_ACTION_ICON_COLOR = { 0.55, 0.55, 0.55, 1 }
+
+local function ensureActionButtonIcon(button)
+ if button._lsbActionIcon then
+ return button._lsbActionIcon
+ end
+
+ local icon = button:CreateTexture(nil, "ARTWORK")
+ icon:SetPoint("CENTER", button, "CENTER", 0, 0)
+ icon:Hide()
+ button._lsbActionIcon = icon
+ return icon
+end
+
+local function applyActionButtonIcon(button, action, enabled)
+ local icon = button._lsbActionIcon
+ local iconTexture = action and action.iconTexture
+ if not iconTexture then
+ if icon then
+ setTextureValue(icon, nil)
+ if icon.SetDesaturated then
+ icon:SetDesaturated(false)
+ end
+ if icon.SetVertexColor then
+ icon:SetVertexColor(1, 1, 1, 1)
+ end
+ icon:Hide()
+ end
+ return
+ end
+
+ local disabled = enabled == false
+ icon = ensureActionButtonIcon(button)
+ icon:ClearAllPoints()
+ icon:SetPoint("CENTER", button, "CENTER", 0, 0)
+ icon:SetSize(action.iconSize or 16, action.iconSize or 16)
+ icon:SetAlpha(disabled and (action.disabledIconAlpha or action.iconAlpha or 1) or (action.iconAlpha or 1))
+ if icon.SetDesaturated then
+ icon:SetDesaturated(disabled)
+ end
+ if icon.SetVertexColor then
+ if disabled then
+ icon:SetVertexColor(
+ DISABLED_ACTION_ICON_COLOR[1],
+ DISABLED_ACTION_ICON_COLOR[2],
+ DISABLED_ACTION_ICON_COLOR[3],
+ DISABLED_ACTION_ICON_COLOR[4]
+ )
+ else
+ icon:SetVertexColor(1, 1, 1, 1)
+ end
+ end
+ setTextureValue(icon, iconTexture)
+ icon:Show()
+
+ if button.SetText then
+ button:SetText("")
+ end
+end
+
+local function applyActionButtonState(button, enabled)
+ local interactive = enabled ~= false
+ if button.EnableMouse then
+ button:EnableMouse(interactive)
+ end
+ if button.UnlockHighlight then
+ button:UnlockHighlight()
+ end
+
+ local highlight = button.GetHighlightTexture and button:GetHighlightTexture() or nil
+ if highlight and highlight.SetAlpha then
+ if interactive then
+ highlight:SetAlpha(button._lsbDisabledHighlightAlpha or 1)
+ button._lsbDisabledHighlightAlpha = nil
+ else
+ if button._lsbDisabledHighlightAlpha == nil and highlight.GetAlpha then
+ button._lsbDisabledHighlightAlpha = highlight:GetAlpha()
+ end
+ highlight:SetAlpha(0)
+ end
+ end
+end
+
+local function resetActionButton(button)
+ button:ClearAllPoints()
+ button:SetScript("OnClick", nil)
+ button:SetScript("OnEnter", nil)
+ button:SetScript("OnLeave", nil)
+ button:Hide()
+end
local function ensureActionsCollectionRow(row)
if row._lsbActionsRow then
@@ -340,12 +457,19 @@ local function ensureActionsCollectionRow(row)
row._label:SetWordWrap(false)
row._buttons = {}
+ row._textureButtons = {}
for _, key in ipairs(ACTION_BUTTON_ORDER) do
local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
if button.RegisterForClicks then
button:RegisterForClicks("LeftButtonDown")
end
row._buttons[key] = button
+
+ button = CreateFrame("Button", nil, row)
+ if button.RegisterForClicks then
+ button:RegisterForClicks("LeftButtonDown")
+ end
+ row._textureButtons[key] = button
end
end
@@ -359,15 +483,15 @@ local function refreshActionsCollectionRow(row, item)
local anchor = nil
for _, key in ipairs(ACTION_BUTTON_ORDER) do
- local button = row._buttons[key]
+ local templateButton = row._buttons[key]
+ local textureButton = row._textureButtons[key]
local action = item.actions and item.actions[key] or nil
- button:ClearAllPoints()
- button:SetScript("OnClick", nil)
- button:SetScript("OnEnter", nil)
- button:SetScript("OnLeave", nil)
+ resetActionButton(templateButton)
+ resetActionButton(textureButton)
if action and not evaluateStaticOrFunction(action.hidden, action, row, item) then
+ local button = action.buttonTextures and textureButton or templateButton
if not anchor then
button:SetPoint("RIGHT", row, "RIGHT", -ACTION_BUTTON_SPACING, 0)
else
@@ -379,9 +503,11 @@ local function refreshActionsCollectionRow(row, item)
enabled = true
end
applyActionButtonTextures(button, action, enabled)
+ applyActionButtonIcon(button, action, enabled)
if button.SetEnabled then
button:SetEnabled(enabled)
end
+ applyActionButtonState(button, enabled)
setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, row, item))
button:SetScript("OnClick", function()
if action.onClick then
@@ -390,8 +516,6 @@ local function refreshActionsCollectionRow(row, item)
end)
button:Show()
anchor = button
- else
- button:Hide()
end
end
@@ -414,6 +538,11 @@ local function ensureModeInputRow(row)
row._lsbModeInputRow = true
row:SetHeight(28)
+ row._background = row:CreateTexture(nil, "BACKGROUND")
+ row._background:SetColorTexture(1, 1, 1, 0.05)
+ row._background:SetPoint("TOPLEFT", row, "TOPLEFT", -4, 2)
+ row._background:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 4, -2)
+
row._modeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
row._modeButton:SetPoint("LEFT", row, "LEFT", 0, 0)
row._modeButton:SetSize(58, 22)
@@ -697,7 +826,7 @@ local function ensureSectionHeaderRow(content, headers, sectionKey, title)
end
row = CreateFrame("Frame", nil, content)
- row:SetHeight(28)
+ row:SetHeight(SECTION_HEADER_HEIGHT)
row._title = internal.createHeaderTitle(row, title)
headers[sectionKey] = row
return row
@@ -757,7 +886,7 @@ local function refreshSectionedCollection(frame, data)
header:SetPoint("TOPLEFT", content, "TOPLEFT", 0, y)
header:SetPoint("RIGHT", content, "RIGHT", 0, 0)
header:Show()
- y = y - (section.headerHeight or 28)
+ y = y - (section.headerHeight or SECTION_HEADER_HEIGHT)
local items = section.items or {}
local pool = rowPools[sectionKey] or {}
diff --git a/Tests/UI/BuffBarsOptions_spec.lua b/Tests/UI/BuffBarsOptions_spec.lua
index 8e7b819b..df132343 100644
--- a/Tests/UI/BuffBarsOptions_spec.lua
+++ b/Tests/UI/BuffBarsOptions_spec.lua
@@ -666,10 +666,8 @@ describe("BuffBarsOptions", function()
assert.is_true(externalHeader.disabled())
assert.is_true(buffItems[1].color.enabled())
assert.is_false(externalItems[1].color.enabled())
- assert.are.equal(0.5, externalItems[1].alpha)
- assert.is_true(externalItems[1].iconDesaturated)
- assert.are.equal(0.5, externalItems[2].alpha)
- assert.is_true(externalItems[2].iconDesaturated)
+ assert.is_true(externalItems[1].disabled)
+ assert.is_true(externalItems[2].disabled)
end)
it("ctrl-hovering a spell color collection row shows all keys for that row", function()
@@ -721,7 +719,46 @@ describe("BuffBarsOptions", function()
assert.are.same(selectedColor, BuffSpellColors:GetDefaultColor())
assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ ns.L["SPELL_COLORS_SUBCAT"] }, refreshCalls)
+ assert.are.same({}, refreshCalls)
+ end)
+
+ it("regression: live spell color picker changes do not refresh the page or block reopening", function()
+ local firstColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+ local secondColor = { r = 0.1, g = 0.2, b = 0.3, a = 1 }
+ local pickedColors = { firstColor, secondColor }
+ local pickerCalls = 0
+ local scheduledReasons = {}
+ local rowColor
+
+ -- Regression guard: refreshing this options page from the live color-picker path
+ -- broke subsequent swatch clicks in-game. Do not weaken the empty refresh assertion.
+ ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
+ pickerCalls = pickerCalls + 1
+ assert.is_false(hasOpacity)
+ onChange(assert(pickedColors[pickerCalls], "unexpected color-picker reopen"))
+ end
+ ns.Runtime.ScheduleLayoutUpdate = function(_, reason)
+ scheduledReasons[#scheduledReasons + 1] = reason
+ end
+
+ local spellColorsSpec, refreshCalls = registerSpellColorsSpec()
+ local defaultItem = getSpellColorCollectionItems(spellColorsSpec, "buffBars")[1]
+ local rowFrame = {
+ _swatch = {
+ SetColorRGB = function(_, r, g, b)
+ rowColor = { r = r, g = g, b = b }
+ end,
+ },
+ }
+
+ defaultItem.color.onClick(defaultItem, rowFrame)
+ defaultItem.color.onClick(defaultItem, rowFrame)
+
+ assert.are.equal(2, pickerCalls)
+ assert.are.same(secondColor, BuffSpellColors:GetDefaultColor())
+ assert.are.same({ r = secondColor.r, g = secondColor.g, b = secondColor.b }, rowColor)
+ assert.are.same({ "OptionsChanged", "OptionsChanged" }, scheduledReasons)
+ assert.are.same({}, refreshCalls)
end)
it("header actions disable reconcile and remove stale when every row is complete", function()
@@ -806,6 +843,35 @@ describe("BuffBarsOptions", function()
assert.is_false(spellColorsSpec.onDefaultEnabled())
end)
+ it("keeps existing color swatches editable while the owner module is edit locked", function()
+ local key = SpellColors.MakeKey("Immolation Aura", 258920, 77, 9001)
+ local pickedColor = { r = 0.7, g = 0.6, b = 0.5, a = 1 }
+ local pickerCalls = 0
+
+ BuffSpellColors:SetColorByKey(key, {
+ r = 0.2, g = 0.3, b = 0.4, a = 1,
+ })
+ ns.Addon.BuffBars.IsEditLocked = function()
+ return true, "secrets"
+ end
+ ns.OptionUtil.OpenColorPicker = function(_, hasOpacity, onChange)
+ pickerCalls = pickerCalls + 1
+ assert.is_false(hasOpacity)
+ onChange(pickedColor)
+ end
+
+ local spellColorsSpec = registerSpellColorsSpec()
+ local buffItems = getSpellColorCollectionItems(spellColorsSpec, "buffBars")
+
+ assert.is_true(buffItems[1].color.enabled())
+ assert.is_true(buffItems[2].color.enabled())
+
+ buffItems[2].color.onClick()
+
+ assert.are.equal(1, pickerCalls)
+ assert.are.same(pickedColor, BuffSpellColors:GetColorByKey(key))
+ end)
+
it("reconcile action uses ConfirmReloadUI for incomplete rows", function()
local confirmText
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 6ee623b2..735b2418 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -858,9 +858,8 @@ describe("ExtraIconsOptions settings page", function()
assert.is_table(opts._draftStates)
assert.are.equal("checkbox", getRow("enabled").type)
- assert.are.equal("info", getRow("specialRowsLegend").type)
assert.are.equal("sectionList", getRow("viewers").type)
- assert.are.equal(ns.L["EXTRA_ICONS_SPECIAL_ROWS_LEGEND"], getRow("specialRowsLegend").value)
+ assert.are.equal(2, #capturedPage.rows)
end)
it("builds utility and main sections with placeholder rows and footers", function()
@@ -879,7 +878,7 @@ describe("ExtraIconsOptions settings page", function()
end))
end)
- it("maps row actions to built-in button icons", function()
+ it("maps row actions to built-in button texture states", function()
_G.C_Spell = {
GetSpellName = function(spellId)
return spellId == 12345 and "Test Spell" or nil
@@ -898,28 +897,36 @@ describe("ExtraIconsOptions settings page", function()
end))
assert.are.equal(
"Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
- custom.actions.up.iconTexture
+ custom.actions.up.buttonTextures.normal
+ )
+ assert.are.equal(
+ "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Down",
+ custom.actions.up.buttonTextures.pushed
)
assert.are.equal(
"Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
- custom.actions.down.iconTexture
+ custom.actions.down.buttonTextures.normal
)
assert.are.equal(
"Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
- custom.actions.move.iconTexture
+ custom.actions.move.buttonTextures.normal
)
assert.are.equal(
"Interface\\Buttons\\UI-GroupLoot-Pass-Up",
- custom.actions.delete.iconTexture
+ custom.actions.delete.buttonTextures.normal
)
- assert.is_nil(custom.actions.delete.buttonTextures)
+ assert.is_nil(custom.actions.delete.iconTexture)
+ assert.are.equal("", custom.actions.up.text)
+ assert.are.equal("", custom.actions.down.text)
+ assert.are.equal("", custom.actions.move.text)
+ assert.are.equal("", custom.actions.delete.text)
local activeBuiltin = assert(findItem("utility", function(item)
return item.label == "Healthstones"
end))
assert.are.equal(
"Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
- activeBuiltin.actions.delete.iconTexture
+ activeBuiltin.actions.delete.buttonTextures.normal
)
local builtinPlaceholder = assert(findItem("utility", function(item)
@@ -927,7 +934,7 @@ describe("ExtraIconsOptions settings page", function()
end))
assert.are.equal(
"Interface\\Buttons\\UI-PlusButton-Up",
- builtinPlaceholder.actions.delete.iconTexture
+ builtinPlaceholder.actions.delete.buttonTextures.normal
)
end)
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index f60ed17c..4174391b 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -36,6 +36,7 @@ describe("OptionUtil", function()
"LibStub",
"CreateFromMixins",
"SettingsListElementInitializer",
+ "ColorPickerFrame",
})
end)
@@ -88,6 +89,36 @@ describe("OptionUtil", function()
optionsModule = ns.Addon._modules.Options
end)
+ it("ignores setup swatch callbacks and tolerates missing cancel state", function()
+ local pickerConfig
+ local changes = {}
+
+ _G.ColorPickerFrame = {
+ SetupColorPickerAndShow = function(_, config)
+ pickerConfig = config
+ config.swatchFunc()
+ end,
+ GetColorRGB = function()
+ return 0.1, 0.2, 0.3
+ end,
+ GetColorAlpha = function()
+ return 0.4
+ end,
+ }
+
+ ns.OptionUtil.OpenColorPicker({ r = 0.7, g = 0.8, b = 0.9, a = 0.6 }, true, function(color)
+ changes[#changes + 1] = color
+ end)
+
+ assert.are.equal(0, #changes)
+
+ pickerConfig.swatchFunc()
+ assert.are.same({ r = 0.1, g = 0.2, b = 0.3, a = 0.4 }, changes[1])
+
+ pickerConfig.cancelFunc(nil)
+ assert.are.same({ r = 0.7, g = 0.8, b = 0.9, a = 0.6 }, changes[2])
+ end)
+
describe("About page spec", function()
it("registers the root About page with ordered rows", function()
local _, registeredPage = TestHelpers.RegisterRootPageSpec(
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 8a61e436..06cd9df9 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -14,7 +14,6 @@ local BUILTIN_STACK_ORDER = C.BUILTIN_STACK_ORDER
local RACIAL_ABILITIES = C.RACIAL_ABILITIES
local VIEWER_COLLECTION_HEIGHT = 448
-local VIEWER_SECTION_HEADER_HEIGHT = 50
local ACTION_ICON_BUTTON_SIZE = 20
local DEFAULT_SPECIAL_VIEWER = "utility"
local VIEWER_ORDER = { "utility", "main" }
@@ -27,18 +26,43 @@ local VIEWER_SHORT_LABELS = {
main = L["MAIN_VIEWER_SHORT"],
}
-local ACTION_BUTTON_ICONS = {
- delete = "Interface\\Buttons\\UI-GroupLoot-Pass-Up",
- hide = "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
- moveDown = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
- moveLeft = "Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Up",
- moveRight = "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
- moveUp = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
- show = "Interface\\Buttons\\UI-PlusButton-Up",
+local ACTION_BUTTON_TEXTURES = {
+ delete = {
+ normal = "Interface\\Buttons\\UI-GroupLoot-Pass-Up",
+ pushed = "Interface\\Buttons\\UI-GroupLoot-Pass-Down",
+ disabled = "Interface\\Buttons\\UI-GroupLoot-Pass-Disabled",
+ },
+ hide = {
+ normal = "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
+ pushed = "Interface\\Buttons\\UI-Panel-MinimizeButton-Down",
+ disabled = "Interface\\Buttons\\UI-Panel-MinimizeButton-Disabled",
+ },
+ moveDown = {
+ normal = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
+ pushed = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Down",
+ disabled = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Disabled",
+ },
+ moveLeft = {
+ normal = "Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Up",
+ pushed = "Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Down",
+ disabled = "Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Disabled",
+ },
+ moveRight = {
+ normal = "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up",
+ pushed = "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Down",
+ disabled = "Interface\\Buttons\\UI-SpellbookIcon-NextPage-Disabled",
+ },
+ moveUp = {
+ normal = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up",
+ pushed = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Down",
+ disabled = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Disabled",
+ },
+ show = {
+ normal = "Interface\\Buttons\\UI-PlusButton-Up",
+ pushed = "Interface\\Buttons\\UI-PlusButton-Down",
+ disabled = "Interface\\Buttons\\UI-PlusButton-Disabled",
+ },
}
-local ENABLED_LABEL_COLOR = { 1, 0.82, 0, 1 }
-local DISABLED_LABEL_COLOR = { 0.65, 0.65, 0.65, 1 }
-local DISABLED_ICON_COLOR = { 0.6, 0.6, 0.6, 1 }
local BUILTIN_STACK_SET = {}
local BUILTIN_EQUIP_SLOTS = {}
@@ -598,12 +622,12 @@ local function addDraftEntry(viewerKey)
return true
end
-local function makeAction(text, iconTexture, enabled, tooltip, onClick)
+local function makeAction(text, buttonTextures, enabled, tooltip, onClick)
return {
- text = text,
+ text = buttonTextures and "" or text,
width = ACTION_ICON_BUTTON_SIZE,
height = ACTION_ICON_BUTTON_SIZE,
- iconTexture = iconTexture,
+ buttonTextures = buttonTextures,
enabled = enabled,
tooltip = tooltip,
onClick = onClick,
@@ -622,7 +646,7 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
if rowData.isBuiltin then
return makeAction(
rowData.isDisabled and "+" or "x",
- rowData.isDisabled and ACTION_BUTTON_ICONS.show or ACTION_BUTTON_ICONS.hide,
+ rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide,
not controlsDisabled,
rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"],
profileAction(function(profile)
@@ -637,12 +661,12 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
end
if rowData.isCurrentRacial and rowData.isPlaceholder then
- return makeAction("+", ACTION_BUTTON_ICONS.show, not controlsDisabled, L["ADD_ENTRY"], profileAction(function(profile)
+ return makeAction("+", ACTION_BUTTON_TEXTURES.show, not controlsDisabled, L["ADD_ENTRY"], profileAction(function(profile)
ExtraIconsOptions._toggleCurrentRacialRow(profile, rowData.viewerKey, nil, rowData.spellId)
end))
end
- return makeAction("x", ACTION_BUTTON_ICONS.delete, not controlsDisabled, L["REMOVE_TOOLTIP"], function()
+ return makeAction("x", ACTION_BUTTON_TEXTURES.delete, not controlsDisabled, L["REMOVE_TOOLTIP"], function()
StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
onAccept = profileAction(function(profile)
ExtraIconsOptions._removeEntry(profile, rowData.viewerKey, rowData.index)
@@ -651,8 +675,8 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
end)
end
-local function makeReorderAction(rowData, text, iconTexture, enabled, direction)
- return makeAction(text, iconTexture, enabled, direction < 0 and L["MOVE_UP_TOOLTIP"] or L["MOVE_DOWN_TOOLTIP"],
+local function makeReorderAction(rowData, text, buttonTextures, enabled, direction)
+ return makeAction(text, buttonTextures, enabled, direction < 0 and L["MOVE_UP_TOOLTIP"] or L["MOVE_DOWN_TOOLTIP"],
profileAction(function(profile)
ExtraIconsOptions._reorderEntry(profile, rowData.viewerKey, rowData.index, direction)
end))
@@ -678,16 +702,12 @@ local function buildActionItem(rowData)
local posLocked = rowData.isBuiltin and rowData.isDisabled
local canReorder = not controlsDisabled and rowData.activeIndex ~= nil and not posLocked
local canMove = not controlsDisabled and rowData.index ~= nil and not posLocked and not hasMoveDup
- local moveIcon = rowData.viewerKey == "utility" and ACTION_BUTTON_ICONS.moveRight or ACTION_BUTTON_ICONS.moveLeft
+ local moveTextures = rowData.viewerKey == "utility" and ACTION_BUTTON_TEXTURES.moveRight or ACTION_BUTTON_TEXTURES.moveLeft
return {
label = ExtraIconsOptions._getEntryName(displayEntry),
icon = ExtraIconsOptions._getEntryIcon(displayEntry) or 134400,
- alpha = rowData.isDisabled and 0.55 or 1,
- labelFontObject = rowData.isDisabled and (_G.GameFontDisable or _G.GameFontNormal) or _G.GameFontNormal,
- labelColor = rowData.isDisabled and DISABLED_LABEL_COLOR or ENABLED_LABEL_COLOR,
- iconDesaturated = rowData.isDisabled == true,
- iconVertexColor = rowData.isDisabled and DISABLED_ICON_COLOR or nil,
+ disabled = rowData.isDisabled,
onEnter = function(owner)
showRowTooltip(owner, rowData)
end,
@@ -695,12 +715,12 @@ local function buildActionItem(rowData)
GameTooltip_Hide()
end,
actions = {
- up = makeReorderAction(rowData, "^", ACTION_BUTTON_ICONS.moveUp, canReorder and rowData.activeIndex > 1, -1),
- down = makeReorderAction(rowData, "v", ACTION_BUTTON_ICONS.moveDown,
+ up = makeReorderAction(rowData, "^", ACTION_BUTTON_TEXTURES.moveUp, canReorder and rowData.activeIndex > 1, -1),
+ down = makeReorderAction(rowData, "v", ACTION_BUTTON_TEXTURES.moveDown,
canReorder and rowData.activeIndex < rowData.activeCount, 1),
move = makeAction(
rowData.viewerKey == "utility" and ">" or "<",
- moveIcon,
+ moveTextures,
canMove,
function()
return getMoveTooltip(hasMoveDup, posLocked, otherViewer)
@@ -773,7 +793,6 @@ function ExtraIconsOptions.BuildSections()
sections[#sections + 1] = {
key = viewerKey,
title = VIEWER_LABELS[viewerKey],
- headerHeight = VIEWER_SECTION_HEADER_HEIGHT,
items = items,
emptyText = L["EXTRA_ICONS_NO_ENTRIES"],
footer = buildModeInputTrailer(viewerKey),
@@ -811,15 +830,20 @@ ExtraIconsOptions.pages = {
end,
rows = {
{
- id = "enabled", type = "checkbox", path = "enabled",
- name = L["ENABLE_EXTRA_ICONS"], tooltip = L["ENABLE_EXTRA_ICONS_DESC"],
+ id = "enabled",
+ type = "checkbox",
+ path = "enabled",
+ name = L["ENABLE_EXTRA_ICONS"],
+ tooltip = L["ENABLE_EXTRA_ICONS_DESC"],
onSet = function(ctx, value)
ns.OptionUtil.CreateModuleEnabledHandler("ExtraIcons")(ctx, value)
ctx.page:Refresh()
end,
},
{
- id = "viewers", type = "sectionList", height = VIEWER_COLLECTION_HEIGHT,
+ id = "viewers",
+ type = "sectionList",
+ height = VIEWER_COLLECTION_HEIGHT,
disabled = isDisabled,
sections = ExtraIconsOptions.BuildSections,
onDefault = ExtraIconsOptions.ResetToDefaults,
diff --git a/UI/OptionUtil.lua b/UI/OptionUtil.lua
index bb12839f..6c1d5949 100644
--- a/UI/OptionUtil.lua
+++ b/UI/OptionUtil.lua
@@ -237,13 +237,16 @@ function OptionUtil.OpenColorPicker(currentColor, hasOpacity, onChange)
opacity = currentColor.a,
hasOpacity = hasOpacity,
swatchFunc = function()
- if isSettingUp then return end
+ if isSettingUp then
+ return
+ end
local r, g, b = ColorPickerFrame:GetColorRGB()
local a = hasOpacity and ColorPickerFrame:GetColorAlpha() or 1
onChange({ r = r, g = g, b = b, a = a })
end,
cancelFunc = function(prev)
- onChange({ r = prev.r, g = prev.g, b = prev.b, a = hasOpacity and prev.opacity or 1 })
+ local source = prev or currentColor
+ onChange({ r = source.r, g = source.g, b = source.b, a = hasOpacity and (source.opacity or source.a) or 1 })
end,
})
isSettingUp = false
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index f6ad6136..8b4ecd62 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -303,7 +303,7 @@ PowerBarTickMarksOptions.rows = {
type = "list",
variant = "editor",
height = 320,
- rowHeight = C.SCROLL_ROW_HEIGHT_WITH_CONTROLS,
+ rowHeight = 34,
items = buildTickCollectionItems,
},
}
diff --git a/UI/SpellColorsPage.lua b/UI/SpellColorsPage.lua
index 600b356a..73b7506f 100644
--- a/UI/SpellColorsPage.lua
+++ b/UI/SpellColorsPage.lua
@@ -262,6 +262,28 @@ local function doRefreshPage(refreshPage)
end
end
+local function refreshChangedSpellColor()
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+end
+
+local function updatePickerSwatch(row, color)
+ local swatch = row and row._swatch or nil
+ if swatch and swatch.SetColorRGB then
+ swatch:SetColorRGB(color.r or 1, color.g or 1, color.b or 1)
+ end
+end
+
+---@param currentColor ECM_Color
+---@param applyColor fun(color: ECM_Color)
+---@param row Frame|table|nil
+local function openSpellColorPicker(currentColor, applyColor, row)
+ ns.OptionUtil.OpenColorPicker(currentColor, false, function(color)
+ applyColor(color)
+ updatePickerSwatch(row, color)
+ refreshChangedSpellColor()
+ end)
+end
+
local combatRefreshCallback
---@param page Frame|table|nil
@@ -319,21 +341,20 @@ local function removeStaleSpellColorSection(section)
end
---@param section table
----@param refreshPage fun()
+---@param _refreshPage fun()
---@return table[]
-local function buildSpellColorItems(section, refreshPage)
+local function buildSpellColorItems(section, _refreshPage)
local items = {}
local rows = getSectionSpellColorRows(section)
local spellColors = getSpellColors(section.scope)
- local function isInteractionDisabled()
- return isSpellColorSectionInteractionDisabled(section)
+ local function isSwatchDisabled()
+ return isSpellColorSectionDisabled(section)
end
local function decorateItem(item)
if isSpellColorSectionDisabled(section) then
- item.alpha = 0.5
- item.iconDesaturated = true
+ item.disabled = true
end
return item
end
@@ -343,19 +364,16 @@ local function buildSpellColorItems(section, refreshPage)
color = {
value = spellColors:GetDefaultColor(),
enabled = function()
- return not isInteractionDisabled()
+ return not isSwatchDisabled()
end,
- onClick = function()
- if isInteractionDisabled() then
+ onClick = function(_, row)
+ if isSwatchDisabled() then
return
end
- ns.OptionUtil.OpenColorPicker(spellColors:GetDefaultColor(), false, function(color)
+ openSpellColorPicker(spellColors:GetDefaultColor(), function(color)
spellColors:SetDefaultColor(color)
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
- end)
+ end, row)
end,
},
})
@@ -367,20 +385,17 @@ local function buildSpellColorItems(section, refreshPage)
color = {
value = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor(),
enabled = function()
- return not isInteractionDisabled()
+ return not isSwatchDisabled()
end,
- onClick = function()
- if isInteractionDisabled() then
+ onClick = function(_, rowFrame)
+ if isSwatchDisabled() then
return
end
local current = spellColors:GetColorByKey(row.key) or spellColors:GetDefaultColor()
- ns.OptionUtil.OpenColorPicker(current, false, function(color)
+ openSpellColorPicker(current, function(color)
spellColors:SetColorByKey(row.key, color)
- refreshSpellColors(function()
- doRefreshPage(refreshPage)
- end)
- end)
+ end, rowFrame)
end,
},
onEnter = function(owner)
@@ -552,7 +567,6 @@ local function createSpellColorListRow(section, refreshPage)
type = "list",
variant = "swatch",
height = 180,
- rowHeight = C.SCROLL_ROW_HEIGHT_COMPACT,
items = function()
return buildSpellColorItems(section, refreshPage)
end,
@@ -569,7 +583,7 @@ local function createSecretNameDescriptionRow(section)
value = L["SPELL_COLORS_SECRET_NAMES_DESC"],
wide = true,
multiline = true,
- height = C.SPELL_COLORS_SECRET_NAMES_DESC_HEIGHT,
+ height = 40,
hidden = function()
return not getSectionSpellColorPageState(section).showSecretNameWarning
end,
diff --git a/docs/ExtraIcons.md b/docs/ExtraIcons.md
index de1d2027..f81ce423 100644
--- a/docs/ExtraIcons.md
+++ b/docs/ExtraIcons.md
@@ -62,7 +62,7 @@ Registers through the root/section/page API and exposes only native controls plu
*Racials.* The current-player racial is synthesized as a disabled placeholder in the utility viewer when absent; racial lookup uses only the `UnitRace("player")` race file token, with no normalization, spellbook, or localized-name fallback. Adding it writes a normal spell entry, and removing it returns the UI to that placeholder state. Racials from other races are filtered out of the settings list even if they remain in saved variables.
-*Lifecycle.* Special-row behavior is explained through a short legend plus row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
+*Lifecycle.* Special-row behavior is explained through row-specific tooltips. Section-list rows stay on the current lifecycle path so viewer switches do not lose or misplace embedded content.
## Actor Diagram
From 76041b904d72d043017f5a5a9d9e697f18fae218 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sun, 26 Apr 2026 18:22:52 +1000
Subject: [PATCH 35/53] - Active racial removal now skips the confirmation
popup. - Row tooltips now only trigger from a text-sized hover hitbox, not
the whole horizontal row area. - Hover highlight now works while the tooltip
is displayed. - Built-in Hide uses the same icon as Remove. - Hide tooltip is
now: Hide this built-in set. It can be added again. - Move tooltip is now:
Move to other viewer - Disabled row buttons no longer show button tooltips. -
Footer Add is disabled and inert until the item/spell ID is valid.
---
.../Controls/CollectionFrames.lua | 124 +++++++--
Libs/LibSettingsBuilder/Core.lua | 8 +-
.../LibSettingsBuilder/Tests/Builder_spec.lua | 33 +++
.../Tests/Collections_spec.lua | 253 ++++++++++++++++++
Libs/LibSettingsBuilder/Utility.lua | 19 +-
Libs/LibSettingsBuilder/docs/API_REFERENCE.md | 6 +-
Locales/en.lua | 4 +-
Tests/TestHelpers.lua | 4 +
Tests/UI/ExtraIconsOptions_spec.lua | 63 ++++-
Tests/UI/PowerBarOptions_spec.lua | 7 +-
UI/ExtraIconsOptions.lua | 25 +-
11 files changed, 497 insertions(+), 49 deletions(-)
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index d36ad9d1..2c4389b5 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -22,12 +22,26 @@ local setTextureValue = internal.setTextureValue
local showFrame = internal.showFrame
local DISABLED_ROW_ALPHA = 0.5
+local DEFAULT_LABEL_COLOR = { 1, 1, 1, 1 }
+
+local function getFontObjectTextColor(fontObject)
+ if type(fontObject) == "string" then
+ fontObject = _G[fontObject]
+ end
+ if fontObject and fontObject.GetTextColor then
+ local r, g, b, a = fontObject:GetTextColor()
+ if r then
+ return r, g, b, a
+ end
+ end
+
+ return DEFAULT_LABEL_COLOR[1], DEFAULT_LABEL_COLOR[2], DEFAULT_LABEL_COLOR[3], DEFAULT_LABEL_COLOR[4]
+end
local function applyCollectionRowStyle(row, item)
local disabled = item and item.disabled == true
local alpha = item and item.alpha or (disabled and DISABLED_ROW_ALPHA or 1)
- local labelFontObject = item and item.labelFontObject
- or (disabled and (_G.GameFontDisable or _G.GameFontNormal) or nil)
+ local labelFontObject = item and item.labelFontObject or (disabled and _G.GameFontDisable or _G.GameFontNormal)
local iconDesaturated = item and item.iconDesaturated
if iconDesaturated == nil then
iconDesaturated = disabled
@@ -36,13 +50,17 @@ local function applyCollectionRowStyle(row, item)
if row._label and row._label.SetFontObject and labelFontObject then
row._label:SetFontObject(labelFontObject)
end
- if row._label and row._label.SetTextColor and item and item.labelColor then
- row._label:SetTextColor(
- item.labelColor[1] or 1,
- item.labelColor[2] or 1,
- item.labelColor[3] or 1,
- item.labelColor[4] or 1
- )
+ if row._label and row._label.SetTextColor then
+ if item and item.labelColor then
+ row._label:SetTextColor(
+ item.labelColor[1] or 1,
+ item.labelColor[2] or 1,
+ item.labelColor[3] or 1,
+ item.labelColor[4] or 1
+ )
+ else
+ row._label:SetTextColor(getFontObjectTextColor(labelFontObject))
+ end
end
if row._label and row._label.SetAlpha then
row._label:SetAlpha(alpha)
@@ -63,12 +81,44 @@ local function applyCollectionRowStyle(row, item)
end
end
+local function setCollectionRowHighlight(row, shown)
+ local highlight = row and row._highlight
+ if shown then
+ if highlight and highlight.Show then
+ highlight:Show()
+ end
+ elseif highlight and highlight.Hide then
+ highlight:Hide()
+ end
+end
+
+local function getLabelHitBoxWidth(label)
+ local textWidth = label and label.GetStringWidth and label:GetStringWidth() or nil
+ local labelWidth = label and label.GetWidth and label:GetWidth() or nil
+ if textWidth and labelWidth and labelWidth > 0 then
+ textWidth = math.min(textWidth, labelWidth)
+ end
+ return math.max(1, math.ceil(textWidth or labelWidth or 1))
+end
+
+local function updateActionRowTooltipOwner(row)
+ local owner = row._tooltipOwner
+ if not owner then
+ return
+ end
+
+ owner:ClearAllPoints()
+ owner:SetPoint("LEFT", row._label, "LEFT", 0, 0)
+ owner:SetSize(getLabelHitBoxWidth(row._label), row:GetHeight() or 20)
+end
+
local function bindCollectionRowTooltip(row, item)
if not row or not row.SetScript then
return
end
local label = row._label
+ local tooltipOwner = row._tooltipOwner or label
if row.EnableMouse then
row:EnableMouse(item ~= nil)
end
@@ -82,30 +132,40 @@ local function bindCollectionRowTooltip(row, item)
if label and label.EnableMouse then
label:EnableMouse(false)
end
+ if tooltipOwner and tooltipOwner ~= label then
+ tooltipOwner:SetScript("OnEnter", nil)
+ tooltipOwner:SetScript("OnLeave", nil)
+ if tooltipOwner.EnableMouse then
+ tooltipOwner:EnableMouse(false)
+ end
+ if tooltipOwner.Hide then
+ tooltipOwner:Hide()
+ end
+ end
if not item then
return
end
row:SetScript("OnEnter", function(self)
- if self._highlight and self._highlight.Show then
- self._highlight:Show()
- end
+ setCollectionRowHighlight(self, true)
end)
row:SetScript("OnLeave", function(self)
- if self._highlight and self._highlight.Hide then
- self._highlight:Hide()
- end
+ setCollectionRowHighlight(self, false)
end)
- if not label or not label.SetScript or (not item.onEnter and not item.tooltip) then
+ if not tooltipOwner or not tooltipOwner.SetScript or (not item.onEnter and not item.tooltip) then
return
end
- if label.EnableMouse then
- label:EnableMouse(true)
+ if tooltipOwner.EnableMouse then
+ tooltipOwner:EnableMouse(true)
end
- label:SetScript("OnEnter", function(self)
+ if tooltipOwner.Show then
+ tooltipOwner:Show()
+ end
+ tooltipOwner:SetScript("OnEnter", function(self)
+ setCollectionRowHighlight(row, true)
if item.onEnter then
item.onEnter(self, item)
elseif GameTooltip then
@@ -117,7 +177,8 @@ local function bindCollectionRowTooltip(row, item)
GameTooltip:Show()
end
end)
- label:SetScript("OnLeave", function(self)
+ tooltipOwner:SetScript("OnLeave", function(self)
+ setCollectionRowHighlight(row, false)
if item.onLeave then
item.onLeave(self, item)
elseif GameTooltip_Hide then
@@ -455,19 +516,21 @@ local function ensureActionsCollectionRow(row)
row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
row._label:SetJustifyH("LEFT")
row._label:SetWordWrap(false)
+ row._tooltipOwner = CreateFrame("Frame", nil, row)
+ row._tooltipOwner:Hide()
row._buttons = {}
row._textureButtons = {}
for _, key in ipairs(ACTION_BUTTON_ORDER) do
local button = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
if button.RegisterForClicks then
- button:RegisterForClicks("LeftButtonDown")
+ button:RegisterForClicks("LeftButtonUp")
end
row._buttons[key] = button
button = CreateFrame("Button", nil, row)
if button.RegisterForClicks then
- button:RegisterForClicks("LeftButtonDown")
+ button:RegisterForClicks("LeftButtonUp")
end
row._textureButtons[key] = button
end
@@ -479,7 +542,6 @@ local function refreshActionsCollectionRow(row, item)
row._label:SetText(item.label or "")
setTextureValue(row._icon, item.icon or 134400)
applyCollectionRowStyle(row, item)
- bindCollectionRowTooltip(row, item)
local anchor = nil
for _, key in ipairs(ACTION_BUTTON_ORDER) do
@@ -508,7 +570,7 @@ local function refreshActionsCollectionRow(row, item)
button:SetEnabled(enabled)
end
applyActionButtonState(button, enabled)
- setSimpleTooltip(button, evaluateStaticOrFunction(action.tooltip, action, row, item))
+ setSimpleTooltip(button, enabled ~= false and evaluateStaticOrFunction(action.tooltip, action, row, item) or nil)
button:SetScript("OnClick", function()
if action.onClick then
action.onClick(item, row, action)
@@ -528,6 +590,8 @@ local function refreshActionsCollectionRow(row, item)
row._label:SetPoint("LEFT", row._icon, "RIGHT", 6, 0)
row._label:SetPoint("RIGHT", row, "RIGHT", -6, 0)
end
+ updateActionRowTooltipOwner(row)
+ bindCollectionRowTooltip(row, item)
end
local function ensureModeInputRow(row)
@@ -612,6 +676,12 @@ local function ensureModeInputRow(row)
row._editBox:SetScript("OnEnterPressed", function()
local trailer = row._lsbTrailerData
if trailer and trailer.onSubmit then
+ local disabled = evaluateStaticOrFunction(trailer.disabled, trailer, row, row._lsbSectionData) == true
+ local submitEnabled = evaluateStaticOrFunction(trailer.submitEnabled, trailer, row, row._lsbSectionData)
+ if disabled or submitEnabled == false then
+ return
+ end
+
local keepFocus = trailer.onSubmit(trailer, row, row._lsbSectionData)
if keepFocus then
row._editBox:SetFocus()
@@ -673,6 +743,7 @@ local function refreshModeInputRow(row, trailer, sectionData)
local previewText = getModeInputTrailerValue(currentTrailer, "previewText", activeRow, activeSectionData)
local submitText = getModeInputTrailerValue(currentTrailer, "submitText", activeRow, activeSectionData)
local submitTooltip = getModeInputTrailerValue(currentTrailer, "submitTooltip", activeRow, activeSectionData)
+ local canSubmit = not disabled and submitEnabled ~= false
activeRow._modeButton:SetText(modeText or "")
setSimpleTooltip(activeRow._modeButton, modeTooltip)
@@ -723,7 +794,7 @@ local function refreshModeInputRow(row, trailer, sectionData)
activeRow._submitButton:SetText(submitText or ADD or "Add")
setSimpleTooltip(activeRow._submitButton, submitTooltip)
activeRow._submitButton:SetScript("OnClick", function()
- if currentTrailer.onSubmit then
+ if currentTrailer.onSubmit and canSubmit then
local keepFocus = currentTrailer.onSubmit(currentTrailer, activeRow, activeRow._lsbSectionData)
if keepFocus then
activeRow._editBox:SetFocus()
@@ -732,7 +803,7 @@ local function refreshModeInputRow(row, trailer, sectionData)
end
end)
if activeRow._submitButton.SetEnabled then
- activeRow._submitButton:SetEnabled(not disabled and submitEnabled ~= false)
+ activeRow._submitButton:SetEnabled(canSubmit)
end
end
@@ -919,6 +990,7 @@ local function refreshSectionedCollection(frame, data)
local footer = section.footer
local footerType = footer and (footer.type or footer.preset)
if footerType == "modeInput" then
+ y = y - (section.footerSpacing or data.footerSpacing or 0)
local trailerRow = trailerRows[sectionKey]
if not trailerRow then
trailerRow = CreateFrame("Frame", nil, content)
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 4cee39e6..0910cf44 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -51,7 +51,7 @@
---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition.
---@field sections LibSettingsBuilderSectionConfig[]|nil Gets the optional section definitions registered under the root category.
-local MAJOR, MINOR = "LibSettingsBuilder-1.0", 3
+local MAJOR, MINOR = "LibSettingsBuilder-1.0", 4
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
return
@@ -362,9 +362,9 @@ local function applyActionButtonTextures(button, action, enabled)
end
if textures and textures.normal then
- setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", textures.normal)
- setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", textures.pushed or textures.normal)
- setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", textures.disabled or textures.normal)
+ setButtonTextureState(button, "SetNormalTexture", "GetNormalTexture", textures.normal, nil, 1)
+ setButtonTextureState(button, "SetPushedTexture", "GetPushedTexture", textures.pushed or textures.normal, nil, 1)
+ setButtonTextureState(button, "SetDisabledTexture", "GetDisabledTexture", textures.disabled or textures.normal, nil, 1)
local highlight = textures.highlight
if highlight == nil then
diff --git a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
index 3a2ad015..30a1526a 100644
--- a/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Builder_spec.lua
@@ -116,6 +116,39 @@ describe("LibSettingsBuilder Builder", function()
assert.is_nil(sb:GetPage("general", "missing"))
end)
+ it("uses the section category for an unnamed page in a multi-page section", function()
+ local sb = createBuilder({
+ sections = {
+ {
+ key = "power",
+ name = "Power",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ { type = "info", name = "Enabled", value = "Yes" },
+ },
+ },
+ {
+ key = "ticks",
+ name = "Ticks",
+ rows = {
+ { type = "info", name = "Count", value = "0" },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ local mainPage = assert(sb:GetPage("power", "main"))
+ local ticksPage = assert(sb:GetPage("power", "ticks"))
+
+ assert.are.equal("Builder Spec.Power", mainPage:GetId())
+ assert.are.equal("Builder Spec.Power.Ticks", ticksPage:GetId())
+ assert.are.equal(mainPage._category, ticksPage._category._parent)
+ end)
+
it("registers root-bound composite rows from an empty path", function()
local sb, profile = createBuilder({
sections = {
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index eeb383e4..76edc7d6 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -19,6 +19,8 @@ describe("LibSettingsBuilder Collections", function()
"SettingsListElementMixin",
"CreateDataProvider",
"CreateScrollBoxListLinearView",
+ "GameFontDisable",
+ "GameFontNormal",
"ScrollUtil",
})
end)
@@ -34,6 +36,16 @@ describe("LibSettingsBuilder Collections", function()
_G.SettingsListElementMixin = {}
_G.SettingsDropdownControlMixin = {}
_G.SettingsSliderControlMixin = {}
+ _G.GameFontDisable = {
+ GetTextColor = function()
+ return 0.5, 0.5, 0.5, 1
+ end,
+ }
+ _G.GameFontNormal = {
+ GetTextColor = function()
+ return 1, 0.82, 0, 1
+ end,
+ }
_G.CreateFrame = function()
return TestHelpers.makeFrame()
end
@@ -62,6 +74,99 @@ describe("LibSettingsBuilder Collections", function()
TestHelpers.LoadLibSettingsBuilder()
end)
+ local function makeCollectionControl(clickedButtons)
+ local control = TestHelpers.makeFrame()
+ local textColor = { 1, 1, 1, 1 }
+ control.SetText = function(self, text)
+ self._text = text
+ end
+ control.GetText = function(self)
+ return self._text or ""
+ end
+ control.SetTexture = function(self, textureValue)
+ self._texture = textureValue
+ end
+ control.GetTexture = function(self)
+ return self._texture
+ end
+ control.GetStringWidth = function(self)
+ return #(self._text or "") * 5
+ end
+ control.SetFontObject = function(self, fontObject)
+ self._fontObject = fontObject
+ end
+ control.SetTextColor = function(_, r, g, b, a)
+ textColor = { r, g, b, a or 1 }
+ end
+ control.GetTextColor = function()
+ return textColor[1], textColor[2], textColor[3], textColor[4]
+ end
+ control.SetWordWrap = function() end
+ control.SetJustifyH = function() end
+ control.SetJustifyV = function() end
+ control.SetAutoFocus = function() end
+ control.SetNumeric = function() end
+ control.SetMaxLetters = function() end
+ control.SetTextInsets = function() end
+ control.SetFocus = function() end
+ control.HighlightText = function() end
+ control.SetEnabled = function(self, enabled)
+ self._enabled = enabled
+ end
+ control.EnableMouse = function(self, enabled)
+ self._mouseEnabled = enabled
+ end
+ control.RegisterForClicks = function(self, ...)
+ self._registeredClicks = { ... }
+ if clickedButtons then
+ clickedButtons[#clickedButtons + 1] = self
+ end
+ end
+ control.CreateFontString = function()
+ return makeCollectionControl(clickedButtons)
+ end
+ control.CreateTexture = function()
+ local texture = makeCollectionControl(clickedButtons)
+ texture.SetDesaturated = function(self, desaturated)
+ self._desaturated = desaturated
+ end
+ texture.SetVertexColor = function(self, r, g, b, a)
+ self._vertexColor = { r, g, b, a }
+ end
+ return texture
+ end
+
+ local function setButtonTexture(self, key, textureValue)
+ self[key] = self[key] or makeCollectionControl(clickedButtons)
+ self[key]:SetTexture(textureValue)
+ end
+ control.SetNormalTexture = function(self, textureValue)
+ setButtonTexture(self, "_normalTexture", textureValue)
+ end
+ control.GetNormalTexture = function(self)
+ return self._normalTexture
+ end
+ control.SetPushedTexture = function(self, textureValue)
+ setButtonTexture(self, "_pushedTexture", textureValue)
+ end
+ control.GetPushedTexture = function(self)
+ return self._pushedTexture
+ end
+ control.SetDisabledTexture = function(self, textureValue)
+ setButtonTexture(self, "_disabledTexture", textureValue)
+ end
+ control.GetDisabledTexture = function(self)
+ return self._disabledTexture
+ end
+ control.SetHighlightTexture = function(self, textureValue)
+ setButtonTexture(self, "_highlightTexture", textureValue)
+ end
+ control.GetHighlightTexture = function(self)
+ return self._highlightTexture
+ end
+ return control
+ end
+
it("creates first-class list and sectionList initializers from raw row specs", function()
local lsb = LibStub("LibSettingsBuilder-1.0")
local SB = lsb.New({
@@ -114,6 +219,154 @@ describe("LibSettingsBuilder Collections", function()
assert.is_function(page.Refresh)
end)
+ it("registers section-list row action buttons for mouse-up clicks", function()
+ local clickedButtons = {}
+
+ _G.CreateFrame = function()
+ return makeCollectionControl(clickedButtons)
+ end
+
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ lsb._internal.applyCollectionFrame(makeCollectionControl(clickedButtons), {
+ sections = function()
+ return {
+ {
+ key = "utility",
+ title = "Utility",
+ items = {
+ {
+ label = "Shadowmeld",
+ actions = {
+ delete = {
+ text = "Remove",
+ },
+ },
+ },
+ },
+ },
+ }
+ end,
+ })
+
+ assert.is_true(#clickedButtons > 0)
+ for _, button in ipairs(clickedButtons) do
+ assert.are.same({ "LeftButtonUp" }, button._registeredClicks)
+ end
+ end)
+
+ it("resets reused section-list row visuals from disabled to enabled", function()
+ _G.CreateFrame = function()
+ return makeCollectionControl()
+ end
+
+ local sections = {
+ {
+ key = "utility",
+ title = "Utility",
+ items = {
+ {
+ label = "Shadowmeld",
+ icon = 58984,
+ tooltip = "Add Shadowmeld",
+ disabled = true,
+ actions = {
+ delete = {
+ buttonTextures = { normal = "add", disabled = "add-disabled" },
+ enabled = false,
+ tooltip = "Add",
+ },
+ },
+ },
+ },
+ },
+ }
+ local data = {
+ sections = function()
+ return sections
+ end,
+ }
+ local host = makeCollectionControl()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+
+ lsb._internal.applyCollectionFrame(host, data)
+ local row = assert(host._lsbSectionRowPools.utility[1])
+ assert.are.same({ 0.5, 0.5, 0.5, 1 }, { row._label:GetTextColor() })
+ assert.are.equal(0.5, row._label:GetAlpha())
+ assert.are.equal(0.4, row._textureButtons.delete:GetAlpha())
+ assert.is_nil(row._textureButtons.delete:GetScript("OnEnter"))
+
+ sections[1].items[1] = {
+ label = "Shadowmeld",
+ icon = 58984,
+ tooltip = "Remove Shadowmeld",
+ disabled = false,
+ actions = {
+ delete = {
+ buttonTextures = { normal = "remove", disabled = "remove-disabled" },
+ enabled = true,
+ tooltip = "Remove",
+ },
+ },
+ }
+ lsb._internal.applyCollectionFrame(host, data)
+
+ assert.are.same({ 1, 0.82, 0, 1 }, { row._label:GetTextColor() })
+ assert.are.equal(1, row._label:GetAlpha())
+ assert.are.equal(1, row._textureButtons.delete:GetAlpha())
+ assert.are.equal(1, row._textureButtons.delete:GetNormalTexture():GetAlpha())
+ assert.are.equal(row._label:GetStringWidth(), row._tooltipOwner:GetWidth())
+ row._tooltipOwner:GetScript("OnEnter")(row._tooltipOwner)
+ assert.is_true(row._highlight:IsShown())
+ assert.is_function(row._textureButtons.delete:GetScript("OnEnter"))
+ end)
+
+ it("keeps mode-input submit disabled until the footer reports a valid value", function()
+ _G.CreateFrame = function()
+ return makeCollectionControl()
+ end
+
+ local valid = false
+ local submitCalls = 0
+ local data = {
+ sections = function()
+ return {
+ {
+ key = "utility",
+ title = "Utility",
+ footer = {
+ type = "modeInput",
+ modeText = "Spell",
+ inputText = function()
+ return valid and "12345" or ""
+ end,
+ submitText = "Add",
+ submitEnabled = function()
+ return valid
+ end,
+ onSubmit = function()
+ submitCalls = submitCalls + 1
+ end,
+ },
+ },
+ }
+ end,
+ }
+ local host = makeCollectionControl()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+
+ lsb._internal.applyCollectionFrame(host, data)
+ local footer = assert(host._lsbSectionTrailerRows.utility)
+ assert.is_false(footer._submitButton._enabled)
+ footer._submitButton:GetScript("OnClick")()
+ assert.are.equal(0, submitCalls)
+
+ valid = true
+ lsb._internal.applyCollectionFrame(host, data)
+ assert.is_true(footer._submitButton._enabled)
+ footer._submitButton:GetScript("OnClick")()
+ assert.are.equal(1, submitCalls)
+ end)
+
it("prevents embedded color swatch clicks from selecting the host settings row", function()
local created
_G.CreateFrame = function()
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 56ea0f82..7db041ca 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -87,6 +87,7 @@
---@field disabled LibSettingsBuilderPredicate|nil Gets the page-level disabled predicate propagated to child rows.
---@field hidden LibSettingsBuilderPredicate|nil Gets the page-level hidden predicate propagated to child rows.
---@field order number|nil Gets the sort order used when a section declares multiple pages.
+---@field useSectionCategory boolean|nil Gets whether a multi-page section page is materialized on the section category instead of under a child category.
--- Declarative section definition registered under `config.sections`.
--- Example (section page):
@@ -720,6 +721,7 @@ local function createPage(owner, key, rows, opts)
_operations = {},
_rowIDs = {},
_registered = false,
+ _useSectionCategory = opts.useSectionCategory == true,
disabled = opts.disabled,
hidden = opts.hidden,
key = key,
@@ -767,11 +769,21 @@ local function registerSection(section)
local builder = section._builder
local nested = #section._pageList > 1
local orderedPages = {}
+ local sectionCategoryPage
for i = 1, #section._pageList do
orderedPages[i] = section._pageList[i]
end
sortByOrder(orderedPages)
+ if nested then
+ for _, page in ipairs(orderedPages) do
+ if page._useSectionCategory then
+ assert(not sectionCategoryPage, "registerSection: only one nested page can use the section category")
+ sectionCategoryPage = page
+ end
+ end
+ end
+
if nested then
section._category = createManagedSubcategory(builder, section.name, section._root._category)
end
@@ -779,7 +791,11 @@ local function registerSection(section)
for _, page in ipairs(orderedPages) do
if nested then
assert(page.name and page.name ~= "", "registerSection: nested pages require spec.name")
- materializePage(page, createManagedSubcategory(builder, page.name, section._category))
+ if page == sectionCategoryPage then
+ materializePage(page, section._category)
+ else
+ materializePage(page, createManagedSubcategory(builder, page.name, section._category))
+ end
else
materializePage(page, createManagedSubcategory(builder, section.name, section._root._category))
end
@@ -876,6 +892,7 @@ local function registerPageDefinition(owner, pageDef, defaultName)
hidden = pageDef.hidden,
order = pageDef.order,
path = pageDef.path,
+ useSectionCategory = pageDef.useSectionCategory or (owner._root ~= nil and pageDef.name == nil),
})
end
diff --git a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
index ca7a2d50..6fbbbdde 100644
--- a/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
+++ b/Libs/LibSettingsBuilder/docs/API_REFERENCE.md
@@ -91,7 +91,7 @@ Section definition fields:
Page definition fields inside `pages`:
- `key`
-- `name` (required for nested/multi-page pages unless you want the section name as the default)
+- `name` (optional; in a multi-page section, omitting it makes that page use the visible section category)
- `path`
- `rows`
- `onShow`
@@ -99,11 +99,13 @@ Page definition fields inside `pages`:
- `disabled`
- `hidden`
- `order`
+- `useSectionCategory` (optional explicit form of the omitted-`name` multi-page behavior)
Notes:
- single-page sections flatten to a single leaf by default,
-- multi-page sections create a visible section node automatically.
+- multi-page sections create a visible section node automatically,
+- one multi-page section page can live directly on that section node; named pages are registered below it.
- page `path` prefixes child `path` fields that do not already contain dots,
- page-level `disabled` and `hidden` values propagate to child rows unless a row overrides them.
diff --git a/Locales/en.lua b/Locales/en.lua
index 773b7fff..aff6a065 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -266,8 +266,8 @@ L["EXTRA_ICONS_RACIAL_PLACEHOLDER_TOOLTIP"] =
L["REMOVE_ENTRY_CONFIRM"] = "Remove %s?"
L["MOVE_UP_TOOLTIP"] = "Move up"
L["MOVE_DOWN_TOOLTIP"] = "Move down"
-L["MOVE_TO_VIEWER_TOOLTIP"] = "Move to %s viewer"
-L["EXTRA_ICONS_HIDE_TOOLTIP"] = "Hide"
+L["MOVE_TO_VIEWER_TOOLTIP"] = "Move to other viewer"
+L["EXTRA_ICONS_HIDE_TOOLTIP"] = "Hide this built-in set. It can be added again."
L["ENABLE_TOOLTIP"] = "Enable"
L["DISABLE_TOOLTIP"] = "Disable"
L["REMOVE_TOOLTIP"] = "Remove"
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index 8e11a6ff..f06821f9 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -900,11 +900,15 @@ function TestHelpers.SetupGameTooltipStub()
_lines = {},
_owner = nil,
_anchor = nil,
+ _point = nil,
_shown = false,
SetOwner = function(self, owner, anchor)
self._owner = owner
self._anchor = anchor
end,
+ SetPoint = function(self, point, relativeTo, relativePoint, x, y)
+ self._point = { point, relativeTo, relativePoint, x, y }
+ end,
ClearLines = function(self)
self._title = nil
self._titleColor = nil
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 735b2418..402b2f0f 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -859,6 +859,7 @@ describe("ExtraIconsOptions settings page", function()
assert.is_table(opts._draftStates)
assert.are.equal("checkbox", getRow("enabled").type)
assert.are.equal("sectionList", getRow("viewers").type)
+ assert.are.equal(4, getRow("viewers").footerSpacing)
assert.are.equal(2, #capturedPage.rows)
end)
@@ -878,6 +879,40 @@ describe("ExtraIconsOptions settings page", function()
end))
end)
+ it("keeps active racial entries fully enabled after replacing the placeholder", function()
+ _G.UnitRace = function() return "Night Elf", "NightElf", 4 end
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 58984 and "Shadowmeld" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 58984 and "shadowmeld-tex" or nil
+ end,
+ }
+
+ local racialPlaceholder = assert(findItem("utility", function(item)
+ return item.actions.delete.tooltip == ns.L["ADD_ENTRY"]
+ end))
+ assert.is_true(racialPlaceholder.disabled)
+ racialPlaceholder.actions.delete.onClick()
+
+ local activeRacial = assert(findItem("utility", function(item)
+ return item.label == "Shadowmeld"
+ end))
+ assert.is_false(activeRacial.disabled)
+ assert.is_true(activeRacial.actions.delete.enabled)
+ assert.are.equal("Interface\\Buttons\\UI-GroupLoot-Pass-Up", activeRacial.actions.delete.buttonTextures.normal)
+
+ local popupShown = false
+ _G.StaticPopup_Show = function()
+ popupShown = true
+ end
+ activeRacial.actions.delete.onClick()
+
+ assert.is_false(popupShown)
+ assert.is_false(ns.ExtraIconsOptions._isRacialPresent(profile.extraIcons.viewers, 58984))
+ end)
+
it("maps row actions to built-in button texture states", function()
_G.C_Spell = {
GetSpellName = function(spellId)
@@ -925,9 +960,10 @@ describe("ExtraIconsOptions settings page", function()
return item.label == "Healthstones"
end))
assert.are.equal(
- "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
+ "Interface\\Buttons\\UI-GroupLoot-Pass-Up",
activeBuiltin.actions.delete.buttonTextures.normal
)
+ assert.are.equal(ns.L["EXTRA_ICONS_HIDE_TOOLTIP"], activeBuiltin.actions.delete.tooltip)
local builtinPlaceholder = assert(findItem("utility", function(item)
return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
@@ -1203,8 +1239,10 @@ describe("ExtraIconsOptions settings page", function()
local placeholder = assert(findItem("utility", function(item)
return item.actions.delete.tooltip == ns.L["ENABLE_TOOLTIP"]
end))
- placeholder.onEnter(CreateFrame("Frame"))
- assert.are.equal("ANCHOR_CURSOR", _G.GameTooltip._anchor)
+ local tooltipOwner = CreateFrame("Frame")
+ placeholder.onEnter(tooltipOwner)
+ assert.are.equal("ANCHOR_NONE", _G.GameTooltip._anchor)
+ assert.are.same({ "BOTTOMLEFT", tooltipOwner, "TOPRIGHT", 0, 0 }, _G.GameTooltip._point)
assert.are.equal(ns.L["EXTRA_ICONS_BUILTIN_PLACEHOLDER_TOOLTIP"], _G.GameTooltip._lines[1])
local duplicateMove = assert(findItem("utility", function(item)
@@ -1215,4 +1253,23 @@ describe("ExtraIconsOptions settings page", function()
duplicateMove.actions.move.tooltip()
)
end)
+
+ it("uses the generic move tooltip when the destination viewer can accept the row", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return "spell-" .. tostring(spellId)
+ end,
+ }
+ profile.extraIcons.viewers.utility = {
+ { kind = "spell", ids = { 12345 } },
+ }
+
+ local row = assert(findItem("utility", function(item)
+ return item.label == "Test Spell"
+ end))
+ assert.are.equal(ns.L["MOVE_TO_VIEWER_TOOLTIP"], row.actions.move.tooltip())
+ end)
end)
diff --git a/Tests/UI/PowerBarOptions_spec.lua b/Tests/UI/PowerBarOptions_spec.lua
index 85d6bb28..622668d9 100644
--- a/Tests/UI/PowerBarOptions_spec.lua
+++ b/Tests/UI/PowerBarOptions_spec.lua
@@ -165,12 +165,13 @@ describe("PowerBarOptions getters/setters/defaults", function()
TestHelpers.LoadChunk("UI/PowerBarOptions.lua", "PowerBarOptions")(nil, ns2)
local _, section = TestHelpers.RegisterSectionSpec(SB2, ns2.PowerBarOptions)
- local powerBarSectionCategory = SB2:GetPage(section.key, "main")._category._parent
+ local powerBarCategory = SB2:GetPage(section.key, "main")._category
local tickMarksCategory = SB2:GetPage(section.key, "tickMarks")._category
- assert.is_not_nil(powerBarSectionCategory)
+ assert.is_not_nil(powerBarCategory)
assert.is_not_nil(tickMarksCategory)
- assert.are.equal(powerBarSectionCategory, tickMarksCategory._parent)
+ assert.are.equal(powerBarCategory, tickMarksCategory._parent)
+ assert.are.equal(ns2.L["ADDON_NAME"] .. "." .. ns2.L["POWER_BAR"], powerBarCategory:GetID())
end)
end)
end)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 06cd9df9..1716f790 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -32,11 +32,6 @@ local ACTION_BUTTON_TEXTURES = {
pushed = "Interface\\Buttons\\UI-GroupLoot-Pass-Down",
disabled = "Interface\\Buttons\\UI-GroupLoot-Pass-Disabled",
},
- hide = {
- normal = "Interface\\Buttons\\UI-Panel-MinimizeButton-Up",
- pushed = "Interface\\Buttons\\UI-Panel-MinimizeButton-Down",
- disabled = "Interface\\Buttons\\UI-Panel-MinimizeButton-Disabled",
- },
moveDown = {
normal = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up",
pushed = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Down",
@@ -238,7 +233,13 @@ local function showRowTooltip(owner, rowData)
end
local displayEntry = rowData.displayEntry
- GameTooltip:SetOwner(owner, "ANCHOR_CURSOR")
+ GameTooltip:SetOwner(owner, "ANCHOR_NONE")
+ if GameTooltip.ClearAllPoints then
+ GameTooltip:ClearAllPoints()
+ end
+ if GameTooltip.SetPoint then
+ GameTooltip:SetPoint("BOTTOMLEFT", owner, "TOPRIGHT", 0, 0)
+ end
if GameTooltip.ClearLines then
GameTooltip:ClearLines()
end
@@ -646,7 +647,7 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
if rowData.isBuiltin then
return makeAction(
rowData.isDisabled and "+" or "x",
- rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.hide,
+ rowData.isDisabled and ACTION_BUTTON_TEXTURES.show or ACTION_BUTTON_TEXTURES.delete,
not controlsDisabled,
rowData.isDisabled and L["ENABLE_TOOLTIP"] or L["EXTRA_ICONS_HIDE_TOOLTIP"],
profileAction(function(profile)
@@ -666,6 +667,13 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
end))
end
+ if rowData.isCurrentRacial then
+ return makeAction("x", ACTION_BUTTON_TEXTURES.delete, not controlsDisabled, L["REMOVE_TOOLTIP"],
+ profileAction(function(profile)
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, rowData.viewerKey, rowData.index)
+ end))
+ end
+
return makeAction("x", ACTION_BUTTON_TEXTURES.delete, not controlsDisabled, L["REMOVE_TOOLTIP"], function()
StaticPopup_Show("ECM_CONFIRM_REMOVE_EXTRA_ICON", ExtraIconsOptions._getEntryName(displayEntry), nil, {
onAccept = profileAction(function(profile)
@@ -689,7 +697,7 @@ local function getMoveTooltip(hasMoveDup, posLocked, otherViewer)
if posLocked then
return L["EXTRA_ICONS_BUILTIN_ORDER_TOOLTIP"]
end
- return L["MOVE_TO_VIEWER_TOOLTIP"]:format(VIEWER_SHORT_LABELS[otherViewer])
+ return L["MOVE_TO_VIEWER_TOOLTIP"]
end
local function buildActionItem(rowData)
@@ -844,6 +852,7 @@ ExtraIconsOptions.pages = {
id = "viewers",
type = "sectionList",
height = VIEWER_COLLECTION_HEIGHT,
+ footerSpacing = 4,
disabled = isDisabled,
sections = ExtraIconsOptions.BuildSections,
onDefault = ExtraIconsOptions.ResetToDefaults,
From dbe1f708cd20b49330dd3da4505fef46558366fb Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 28 Apr 2026 02:00:35 +1000
Subject: [PATCH 36/53] Fix extra icon size. Add extra ID suppiort for racials.
---
.../repo/secret-values-and-deprecated-apis.md | 48 ++++++++++++
.serena/memories/style_and_conventions.md | 76 +++++++++++++------
.serena/memories/suggested_commands.md | 30 +++++---
.serena/memories/task_completion.md | 32 ++++----
Constants.lua | 5 +-
Modules/ExtraIcons.lua | 48 ++++++++++--
Tests/Modules/ExtraIcons_spec.lua | 63 ++++++++++++++-
Tests/UI/ExtraIconsOptions_spec.lua | 35 +++++++++
UI/ExtraIconsOptions.lua | 76 ++++++++++++++-----
9 files changed, 329 insertions(+), 84 deletions(-)
create mode 100644 .serena/memories/repo/secret-values-and-deprecated-apis.md
diff --git a/.serena/memories/repo/secret-values-and-deprecated-apis.md b/.serena/memories/repo/secret-values-and-deprecated-apis.md
new file mode 100644
index 00000000..b6529ef5
--- /dev/null
+++ b/.serena/memories/repo/secret-values-and-deprecated-apis.md
@@ -0,0 +1,48 @@
+# Secret Values and Deprecated Blizzard APIs
+
+## Secret Values
+Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values.
+
+Allowed handling:
+- Nil-check them.
+- Pass them to built-ins/APIs that accept secrets.
+- Store them in locals/upvalues/table values.
+- Concatenate or string-format string/number secrets.
+
+Forbidden handling:
+- Arithmetic, comparisons, boolean tests, length, indexing, assignment-derived logic, iteration, or use as table keys.
+- Do not nil-check or wrap `issecretvalue`, `issecrettable`, or `canaccesstable`.
+- Secret tables may yield secret values or be fully inaccessible; `canaccesstable(table)` only reports access, not contents.
+
+## Deprecated Blizzard APIs (12.0.5)
+Do not use the functions, constants, aliases, or mixins below; they are backward-compat shims and may be removed. Use the modern replacement from Blizzard source, typically a `C_*` namespace method or mixin method.
+
+### Blizzard_Deprecated
+- `GetBattlefieldScore`, `GetBattlefieldStatData`, `UnitIsSpellTarget`, `C_SpellBook.GetSpellBookItemLossOfControlCooldown`
+
+### Blizzard_DeprecatedChatInfo
+- Constants: `CHAT_BUTTON_FLASH_TIME`, `CHAT_TELL_ALERT_TIME`, `MAX_COMMUNITY_NAME_LENGTH`, `MAX_COMMUNITY_NAME_LENGTH_NO_CHANNEL`, `MAX_REMEMBERED_TELLS`, `MESSAGE_SCROLLBUTTON_INITIAL_DELAY`, `MESSAGE_SCROLLBUTTON_SCROLL_DELAY`, `MAX_WOW_CHAT_CHANNELS`, `MAX_CHARACTER_NAME_BYTES`, `NUM_CHAT_WINDOWS`, `MAX_COUNTDOWN_SECONDS`
+- ChatFrameUtil aliases: `Chat_AddSystemMessage`, `Chat_GetChannelColor`, `Chat_GetChannelShortcutName`, `Chat_GetChatCategory`, `Chat_GetChatFrame`, `Chat_GetColoredChatName`, `Chat_GetCommunitiesChannel`, `Chat_GetCommunitiesChannelColor`, `Chat_GetCommunitiesChannelName`, `Chat_ShouldColorChatByClass`, `ChatEdit_ActivateChat`, `ChatEdit_ChooseBoxForSend`, `ChatEdit_DeactivateChat`, `ChatEdit_FocusActiveWindow`, `ChatEdit_GetActiveChatType`, `ChatEdit_GetActiveWindow`, `ChatEdit_GetLastActiveWindow`, `ChatEdit_GetLastTellTarget`, `ChatEdit_HasStickyFocus`, `ChatEdit_InsertLink`, `ChatEdit_LinkItem`, `ChatEdit_SetLastActiveWindow`, `ChatEdit_SetLastTellTarget`, `ChatEdit_SetLastToldTarget`, `ChatEdit_TryInsertChatLink`, `ChatEdit_TryInsertQuestLinkForQuestID`, `ChatFrame_AddCommunitiesChannel`, `ChatFrame_AddMessageEventFilter`, `ChatFrame_CanAddChannel`, `ChatFrame_CanChatGroupPerformExpressionExpansion`, `ChatFrame_ChatPageDown`, `ChatFrame_ChatPageUp`, `ChatFrame_ClearChatFocusOverride`, `ChatFrame_DisplayChatHelp`, `ChatFrame_DisplayGameTime`, `ChatFrame_DisplayGMOTD`, `ChatFrame_DisplayHelpText`, `ChatFrame_DisplayHelpTextSimple`, `ChatFrame_DisplayMacroHelpText`, `ChatFrame_DisplaySystemMessage`, `ChatFrame_DisplaySystemMessageInCurrent`, `ChatFrame_DisplaySystemMessageInPrimary`, `ChatFrame_DisplayTimePlayed`, `ChatFrame_DisplayUsageError`, `ChatFrame_GetChatFocusOverride`, `ChatFrame_GetCommunitiesChannelLocalID`, `ChatFrame_GetCommunityAndStreamFromChannel`, `ChatFrame_GetCommunityAndStreamName`, `ChatFrame_GetFullChannelInfo`, `ChatFrame_GetMobileEmbeddedTexture`, `ChatFrame_OpenChat`, `ChatFrame_RemoveCommunitiesChannel`, `ChatFrame_RemoveMessageEventFilter`, `ChatFrame_ReplyTell`, `ChatFrame_ReplyTell2`, `ChatFrame_ResolveChannelName`, `ChatFrame_ResolvePrefixedChannelName`, `ChatFrame_ScrollDown`, `ChatFrame_ScrollToBottom`, `ChatFrame_ScrollUp`, `ChatFrame_SendTell`, `ChatFrame_SendTellWithMessage`, `ChatFrame_SetChatFocusOverride`, `ChatFrame_TimeBreakDown`, `ChatFrame_TruncateToMaxLength`, `ChatFrame_UpdateChatFrames`, `GetChatTimestampFormat`, `SubstituteChatMessageBeforeSend`
+- ChatFrameMixin aliases: `ChatFrame_AddMessage`, `ChatFrame_AddMessageGroup`, `ChatFrame_AddPrivateMessageTarget`, `ChatFrame_AddSingleMessageType`, `ChatFrame_ContainsChannel`, `ChatFrame_ContainsMessageGroup`, `ChatFrame_ExcludePrivateMessageTarget`, `ChatFrame_GetDefaultChatTarget`, `ChatFrame_ReceiveAllPrivateMessages`, `ChatFrame_RegisterForChannels`, `ChatFrame_RegisterForMessages`, `ChatFrame_RemoveAllChannels`, `ChatFrame_RemoveAllMessageGroups`, `ChatFrame_RemoveChannel`, `ChatFrame_RemoveExcludePrivateMessageTarget`, `ChatFrame_RemoveMessageGroup`, `ChatFrame_RemovePrivateMessageTarget`, `ChatFrame_UnregisterAllMessageGroups`, `ChatFrame_UpdateColorByID`, `ChatFrame_UpdateDefaultChatTarget`
+- ChatFrameEditBoxMixin aliases: `ChatEdit_AddHistory`, `ChatEdit_ClearChat`, `ChatEdit_DoesCurrentChannelTargetMatch`, `ChatEdit_ExtractChannel`, `ChatEdit_ExtractTellTarget`, `ChatEdit_GetChannelTarget`, `ChatEdit_HandleChatType`, `ChatEdit_ParseText`, `ChatEdit_ResetChatType`, `ChatEdit_ResetChatTypeToSticky`, `ChatEdit_SendText`, `ChatEdit_SetDeactivated`, `ChatEdit_UpdateHeader`
+- API: `SendChatMessage`, `DoEmote`, `CancelEmote`
+
+### Blizzard_DeprecatedInstanceEncounter
+- `IsEncounterInProgress`, `IsEncounterSuppressingRelease`, `IsEncounterLimitingResurrections`
+
+### Blizzard_DeprecatedItemScript
+- `GetItemQualityColor`, `GetItemInfoInstant`, `GetItemSetInfo`, `GetItemChildInfo`, `DoesItemContainSpec`, `GetItemGem`, `GetItemCreationContext`, `GetItemIcon`, `GetItemFamily`, `GetItemSpell`, `IsArtifactPowerItem`, `IsCurrentItem`, `IsUsableItem`, `IsHelpfulItem`, `IsHarmfulItem`, `IsConsumableItem`, `IsEquippableItem`, `IsEquippedItem`, `IsEquippedItemType`, `ItemHasRange`, `IsItemInRange`, `GetItemClassInfo`, `GetItemInventorySlotInfo`, `BindEnchant`, `ActionBindsItem`, `ReplaceEnchant`, `ReplaceTradeEnchant`, `ConfirmBindOnUse`, `ConfirmOnUse`, `ConfirmNoRefundOnUse`, `DropItemOnUnit`, `EndBoundTradeable`, `EndRefund`, `GetItemInfo`, `GetDetailedItemLevelInfo`, `GetItemSpecInfo`, `GetItemUniqueness`, `GetItemCount`, `PickupItem`, `GetItemSubClassInfo`, `UseItemByName`, `EquipItemByName`, `ReplaceTradeskillEnchant`, `GetItemCooldown`, `IsCorruptedItem`, `IsCosmeticItem`, `IsDressableItem`
+
+### Blizzard_DeprecatedPvpScript
+- `IsSubZonePVPPOI`, `GetZonePVPInfo`, `TogglePVP`, `SetPVP`
+
+### Blizzard_DeprecatedSpecialization
+- Standard: `GetNumSpecializationsForClassID`, `GetSpecializationInfo`, `GetSpecialization`, `GetActiveSpecGroup`, `GetSpecializationMasterySpells`, `GetTalentInfo`
+- Classic: `SetActiveTalentGroup`, `GetTalentTabInfo`, `GetPrimaryTalentTree`, `GetActiveTalentGroup`, `GetTalentTreeMasterySpells`
+- Constants: `MAX_TALENT_TIERS`, `NUM_TALENT_COLUMNS`
+
+### Blizzard_DeprecatedSpellBook
+- `HUNTER_DISMISS_PET`, `IsPlayerSpell`, `IsSpellKnown`, `IsSpellKnownOrOverridesKnown`, `FindFlyoutSlotBySpellID`, `FindSpellOverrideByID`, `FindBaseSpellByID`
+
+### Blizzard_DeprecatedSpellScript
+- `TargetSpellReplacesBonusTree`, `GetMaxSpellStartRecoveryOffset`, `GetSpellQueueWindow`, `GetSchoolString`, `SpellIsPriorityAura`, `SpellIsSelfBuff`, `SpellGetVisibilityInfo`, `C_Spell.GetSpellLossOfControlCooldown`
\ No newline at end of file
diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md
index 6782678c..3a40ed12 100644
--- a/.serena/memories/style_and_conventions.md
+++ b/.serena/memories/style_and_conventions.md
@@ -1,35 +1,61 @@
# Current Style and Conventions
-## Mandatory Repo Rules
-- Every Lua file keeps the standard GPL copyright header.
-- Keep `ARCHITECTURE.md` in sync with real architecture changes.
-- Target WoW Lua 5.1 only; do not introduce post-5.1 features.
-
-## State and Structure
-- Mutable state belongs on the owning instance as `self._field`, not file-level locals.
-- Prefix private methods/fields with `_`.
+## Authoritative Docs
+- `AGENTS.md` is the repo-wide agent rule source.
+- `README.md` owns user-facing overview, install, and configuration.
+- `ARCHITECTURE.md` owns addon module boundaries, init chain, event flow, and public APIs; keep it current for addon-level design changes.
+- Library READMEs own each library's quick-start, API, schema, and tests: `LibSettingsBuilder`, `LibConsole`, `LibEvent`, and `LibLSMSettingsWidgets`.
+
+## Mandatory Lua Rules
+- Every Lua file starts with the standard Enhanced Cooldown Manager GPL v3 header.
+- Target WoW Lua 5.1 only: no `goto`, labels, or `//`.
- Do not use forward declarations.
- Alias shared modules once at file scope when reused.
-- Keep constants in `Constants.lua`; keep defaults in `Defaults.lua`.
+- Mutable state belongs on the owning instance as `self._field`, not file-level locals; private fields/methods use `_`.
+- Prefer assertions for required parameters over guard/fallback branches.
+
+## Architecture and Ownership
+- Prefer the simplest production code that satisfies current supported runtime requirements. Do not add fallback paths, compatibility branches, or defensive adapters without a concrete supported environment.
+- Keep one source of truth for shared state and derived values: derive once, store once, read everywhere.
+- Prefer loose coupling via events, hooks, callbacks, or messages.
+- Do not add duplicated utilities, trivial passthrough wrappers, or production-only indirection around fixed literals or stable signatures.
+- Extract a helper/wrapper/abstraction only when it has an independently testable contract or at least two callers.
+- Prefer constant lookup tables over pure mapping functions for small fixed domains.
+- Remove dead code, stale fields, impossible branches, and unused locale strings.
+- Clear critical state flags via `pcall` so one error cannot wedge later work.
-## Runtime / Performance
-- Never use `OnUpdate` or frame-rate tickers for feature logic; prefer event-driven work plus a single deferred timer when needed.
-- Reuse tables on hot paths with `wipe()`.
+## Runtime and Performance
+- Never use `OnUpdate` or frame-rate tickers for feature logic; use event-driven updates plus one deferred timer when needed.
+- Reuse hot-path tables with `wipe()`.
+- Avoid snapshot-copying callback lists.
- Cancel superseded timers before scheduling replacement deferred work.
+- Periodic setup must stop once all targets are handled.
+- Defer once when leaving restricted contexts; avoid stacked `C_Timer.After(0)` chains.
- Guard hot-path debug logs with `if ECM.IsDebugEnabled() then`.
-- Periodic setup work must stop once setup is complete.
-- Defer once out of restricted contexts; avoid stacked `C_Timer.After(0)` chains.
-## Architecture Boundaries
-- Prefer loose coupling via events, hooks, callbacks, or messages.
-- Maintain one source of truth for shared state / derived values.
-- Do not add trivial passthrough wrappers or duplicate helpers.
-- Remove dead code, stale fields, unused locale strings, and impossible branches.
-- Shared confirm dialogs should use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`.
+## Code Density
+- Inline single-use locals into their sole call site.
+- Generate repeated structural literals from a constructor; extract a thin wrapper only for repeated 2-3 call sequences.
+- Prefer O(1) set lookups over linear scans for fixed load-time lists.
+- Use compact single-line bodies for trivial functions.
+- Do not assign fields to `nil` just to clear them; only assign fields that will be read later.
+- Closures that differ only in one value should share a parameterized path.
+
+## Tests and Stubs
+- Be skeptical when changing tests to satisfy failures; the failure may be real.
+- Test load order mirrors TOC load order.
+- Test production code directly. Do not mirror/reimplement production logic in specs.
+- Stub the canonical function, not a wrapper or alias. If a stub diverges from real behavior, fix the stub instead of adding production fallbacks.
+- Reuse `Tests/TestHelpers.lua` before adding shared helpers.
+- `StaticPopup_Show` stubs forward `(name, text1, text2, data)` and call `OnAccept(self, data)`.
+- Shared confirm dialogs use `ECM.OptionUtil.MakeConfirmDialog(text)` with `data.onAccept`.
+
+## Libraries, UI Templates, and Migrations
+- Libraries stay self-contained: no ECM internals; tests and docs live with the library; public API changes are intentional and documented.
+- Frame templates must be defined in `.xml`, not by Lua hooks on Blizzard functions such as `Settings.CreateElementInitializer`; XML virtual templates with `mixin="GlobalMixinName"` are multi-addon safe via LibStub.
- Migrations in `Migration.lua` are frozen snapshots and must not depend on live production code.
+- A single style/metric has one owner. If a library renders a widget, the library owns dimensions, padding, fonts, and colors; callers must not redeclare matching defaults or pass redundant override knobs.
-## Tests / Secrets
-- Be skeptical about changing tests to satisfy failures.
-- Test load order should mirror TOC load order.
-- Reuse `Tests/TestHelpers.lua` before inventing new shared helpers.
-- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values; only pass them to APIs/built-ins that accept secrets.
+## Secret Values and Deprecated APIs
+- Treat `UnitPowerMax`, `UnitPower`, `UnitPowerPercent`, and `C_UnitAuras.GetUnitAuraBySpellID` as secret values; see `repo/secret-values-and-deprecated-apis` for exact handling rules.
+- Do not use deprecated Blizzard APIs, constants, aliases, or mixins listed in `repo/secret-values-and-deprecated-apis` for 12.0.5.
\ No newline at end of file
diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md
index 5633df49..c8665c4e 100644
--- a/.serena/memories/suggested_commands.md
+++ b/.serena/memories/suggested_commands.md
@@ -1,34 +1,42 @@
# Suggested Commands
-## Testing
+## Addon Validation
```sh
busted Tests
+luacheck . -q
```
-Runs the full Busted test suite from the project root.
-## Linting
+## Library Validation
```sh
-luacheck . -q
+busted --run libsettingsbuilder
+busted --run libconsole
+busted --run libevent
+busted --run liblsmsettingswidgets
```
-Runs luacheck with quiet output. Config in `.luacheckrc` (std=lua51, excludes libs/ and Tests/).
-## Git (Windows/PowerShell)
+## When They Apply
+- Changes to `Modules/`, `UI/`, or any root-level `*.lua` must pass `busted Tests` and `luacheck . -q`.
+- Changes under `Libs//` must additionally pass that library's suite.
+
+## Useful Git Commands (PowerShell)
```powershell
git status
git diff
git log --oneline -10
git add -A; git commit -m "message"
-git push # aliased as 'gp' in user's shell
+git push
```
-## File Operations (PowerShell)
+## Useful File Commands (PowerShell)
```powershell
+rg "pattern"
+rg --files
Get-ChildItem -Recurse -Filter "*.lua"
Get-Content
Test-Path
```
## Notes
-- The addon runs inside WoW; there is no standalone entry point to execute
-- Tests run via `busted` which is a Lua test framework (must be installed on system)
-- No formatter configured; style is enforced via code review conventions
+- The addon runs inside WoW; there is no standalone runtime entry point.
+- Tests run via `busted` and lint via `luacheck`; both must be available on the system.
+- No formatter is configured; style is enforced by repo conventions and review.
\ No newline at end of file
diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md
index a1cb81d4..3b2b117b 100644
--- a/.serena/memories/task_completion.md
+++ b/.serena/memories/task_completion.md
@@ -1,20 +1,20 @@
# Task Completion Checklist
-## Validation Commands
-- Addon tests: `busted Tests`
-- Library tests: `busted --run libsettingsbuilder`, `busted --run libconsole`, `busted --run libevent`, `busted --run liblsmsettingswidgets`
-- Lint: `luacheck . -q`
+## Required Validation
+- For changes to `Modules/`, `UI/`, or root-level `*.lua`: run `busted Tests` and `luacheck . -q`.
+- For changes under `Libs//`: also run the matching library suite: `busted --run libsettingsbuilder`, `busted --run libconsole`, `busted --run libevent`, or `busted --run liblsmsettingswidgets`.
-## When To Run Validation
-- Treat validation as a pre-commit step, not an every-iteration step.
-- Do not run the full test/lint suites while iterating unless they are needed to debug a specific issue.
-- Before committing a change, updates to `Modules/`, `Helpers/`, `UI/`, and `ECM*.lua` must pass `busted Tests` and `luacheck . -q`.
-- Before committing a library change, also run that library's dedicated test suite.
-
-## Completion Checks
-- Verify new constants live in `Constants.lua`.
-- Keep the copyright header intact on all modified/new Lua files.
-- Keep `ARCHITECTURE.md` current when architecture changes.
-- Review for duplication, dead code, redundant guards/assignments, and avoidable allocations.
+## Before Finishing
+- Keep the standard GPL header intact on every new or modified Lua file.
+- Keep `ARCHITECTURE.md` current for addon-level design changes.
+- Keep the relevant library README current for library API/schema/test changes.
+- Verify new constants live in `Constants.lua`; defaults live in `Defaults.lua`.
+- Review for duplication, dead code, stale fields, redundant guards/fallbacks, unused locale strings, avoidable allocations, and needless abstractions.
- Preserve loose coupling and single-source-of-truth ownership.
-- Do not introduce `OnUpdate` loops or forward declarations.
+- Do not introduce `OnUpdate`, frame-rate tickers, forward declarations, Lua post-5.1 syntax, deprecated Blizzard APIs, or invalid handling of secret values.
+- For UI/library widgets, keep style metrics with the component/library owner; do not redeclare matching defaults from callers.
+
+## Testing Judgment
+- Treat validation as a pre-commit step, not something to run after every small iteration unless a specific failure is being debugged.
+- Be skeptical about editing tests just to satisfy failures; production behavior may be wrong.
+- If validation cannot be run, report that and explain the blocker.
\ No newline at end of file
diff --git a/Constants.lua b/Constants.lua
index 5ee91359..c9518126 100644
--- a/Constants.lua
+++ b/Constants.lua
@@ -105,7 +105,8 @@ local constants = {
-- Extra icons
DEFAULT_EXTRA_ICON_SIZE = 32,
- EXTRA_ICON_BORDER_SCALE = 1.35,
+ EXTRA_ICON_MAIN_BORDER_SCALE = 1.35,
+ EXTRA_ICON_UTILITY_BORDER_SCALE = 1.4,
-- Consumables and equipment slots
COMBAT_POTIONS = {
@@ -227,7 +228,7 @@ local RACIAL_ABILITIES = {
Vulpera = { spellId = 312411 }, -- Bag of Tricks
MagharOrc = { spellId = 274738 }, -- Ancestral Call
Mechagnome = { spellId = 312924 }, -- Hyper Organic Light Originator
- Dracthyr = { spellId = 368970 }, -- Tail Swipe
+ Dracthyr = { spellIds = { 357214, 368970 } }, -- Tail Swipe
EarthenDwarf = { spellId = 436717 }, -- Azerite Surge
}
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 917cacf5..5c12b6c8 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -8,14 +8,30 @@ local ExtraIcons = ns.Addon:NewModule("ExtraIcons")
ns.Addon.ExtraIcons = ExtraIcons
local BUILTIN_STACKS = ns.Constants.BUILTIN_STACKS
+local RACIAL_ABILITIES = ns.Constants.RACIAL_ABILITIES
local DEFAULT_SIZE = ns.Constants.DEFAULT_EXTRA_ICON_SIZE
-local BORDER_SCALE = ns.Constants.EXTRA_ICON_BORDER_SCALE
+local MAIN_BORDER_SCALE = ns.Constants.EXTRA_ICON_MAIN_BORDER_SCALE
+local UTILITY_BORDER_SCALE = ns.Constants.EXTRA_ICON_UTILITY_BORDER_SCALE
+
+local BORDER_SCALE_BY_VIEWER = {
+ main = { MAIN_BORDER_SCALE, MAIN_BORDER_SCALE },
+ -- Utility icon frames render square; keep the overlay square so extras do not look short.
+ utility = { UTILITY_BORDER_SCALE, UTILITY_BORDER_SCALE },
+}
local SUPPRESS_IN_RATED_PVP = {
combatPotions = true,
healthPotions = true,
}
+local RACIAL_SPELL_ALIASES = {}
+for _, racial in pairs(RACIAL_ABILITIES) do
+ local spellIds = racial.spellIds or { racial.spellId }
+ for _, spellId in ipairs(spellIds) do
+ RACIAL_SPELL_ALIASES[spellId] = spellIds
+ end
+end
+
-- Ordered viewer keys mapped to their Blizzard frame globals.
local VIEWERS = {
{ key = "main", blizzKey = "EssentialCooldownViewer" },
@@ -118,12 +134,27 @@ local function resolveItem(ids)
end
end
+local function resolveKnownSpell(spellId)
+ if spellId and C_SpellBook.IsSpellKnown(spellId) then
+ local texture = C_Spell.GetSpellTexture(spellId)
+ if texture then return { spellId = spellId, texture = texture } end
+ end
+end
+
local function resolveSpell(ids)
for _, entry in ipairs(ids) do
local spellId = type(entry) == "table" and entry.spellId or entry
- if spellId and C_SpellBook.IsSpellKnown(spellId) then
- local texture = C_Spell.GetSpellTexture(spellId)
- if texture then return { spellId = spellId, texture = texture } end
+ local data = resolveKnownSpell(spellId)
+ if data then return data end
+
+ local aliases = RACIAL_SPELL_ALIASES[spellId]
+ if aliases then
+ for _, aliasSpellId in ipairs(aliases) do
+ if aliasSpellId ~= spellId then
+ data = resolveKnownSpell(aliasSpellId)
+ if data then return data end
+ end
+ end
end
end
end
@@ -159,7 +190,7 @@ end
-- Icon creation and cooldown
--------------------------------------------------------------------------------
-local function createIcon(parent, size)
+local function createIcon(parent, size, borderScale)
local icon = CreateFrame("Button", nil, parent)
icon:SetSize(size, size)
@@ -184,7 +215,7 @@ local function createIcon(parent, size)
icon.Border = icon:CreateTexture(nil, "OVERLAY")
icon.Border:SetAtlas("UI-HUD-CoolDownManager-IconOverlay")
icon.Border:SetPoint("CENTER")
- icon.Border:SetSize(size * BORDER_SCALE, size * BORDER_SCALE)
+ icon.Border:SetSize(size * borderScale[1], size * borderScale[2])
icon.Shadow = icon:CreateTexture(nil, "OVERLAY")
icon.Shadow:SetAtlas("UI-CooldownManager-OORshadow")
@@ -340,8 +371,9 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
end
for _, icon in ipairs(vs.iconPool) do icon:Hide() end
+ local borderScale = BORDER_SCALE_BY_VIEWER[viewerKey] or BORDER_SCALE_BY_VIEWER.main
for i = #vs.iconPool + 1, #items do
- vs.iconPool[i] = createIcon(container, DEFAULT_SIZE)
+ vs.iconPool[i] = createIcon(container, DEFAULT_SIZE, borderScale)
end
local fontPath, fontSize, fontFlags = getSiblingFont(blizzFrame)
@@ -384,7 +416,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
icon:SetSize(iconSize, iconSize)
icon.Icon:SetSize(iconSize, iconSize)
icon.Mask:SetSize(iconSize, iconSize)
- icon.Border:SetSize(iconSize * BORDER_SCALE, iconSize * BORDER_SCALE)
+ icon.Border:SetSize(iconSize * borderScale[1], iconSize * borderScale[2])
icon.slotId = data.slotId
icon.itemId = data.itemId
icon.spellId = data.spellId
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 1999ed60..7dd5f088 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -345,7 +345,16 @@ describe("ExtraIcons real source", function()
frame.CreateTexture = function()
local texture = TestHelpers.makeTexture()
texture.SetPoint = function() end
- texture.SetSize = function() end
+ texture.SetSize = function(self, width, height)
+ self.__width = width
+ self.__height = height
+ end
+ texture.GetWidth = function(self)
+ return self.__width
+ end
+ texture.GetHeight = function(self)
+ return self.__height
+ end
texture.SetAtlas = function() end
texture.AddMaskTexture = function() end
texture.Hide = function(self)
@@ -357,7 +366,10 @@ describe("ExtraIcons real source", function()
local texture = TestHelpers.makeTexture()
texture.SetAtlas = function() end
texture.SetPoint = function() end
- texture.SetSize = function() end
+ texture.SetSize = function(self, width, height)
+ self.__width = width
+ self.__height = height
+ end
return texture
end
frame.SetAllPoints = function() end
@@ -906,6 +918,31 @@ describe("ExtraIcons real source", function()
assert.are.equal(87, x)
end)
+ it("matches the utility viewer's square overlay footprint", function()
+ local activeFrame = TestHelpers.makeFrame({ shown = true, width = 30, height = 30 })
+ activeFrame.isActive = true
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1.0
+ UtilityCooldownViewer:SetWidth(30)
+ UtilityCooldownViewer.GetItemFrames = function()
+ return { activeFrame }
+ end
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 1
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { stackKey = "healthstones" } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local icon = ExtraIcons._viewers.utility.iconPool[1]
+ assert.are.equal(42, icon.Border.__width)
+ assert.are.equal(42, icon.Border.__height)
+ end)
+
it("skips disabled entries during layout resolution", function()
local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
utilityIconChild.GetSpellID = function() return 1 end
@@ -1382,6 +1419,28 @@ describe("ExtraIcons real source", function()
assert.are.equal("racial-icon", vs.iconPool[1].Icon:GetTexture())
end)
+ it("resolves saved racial spell ids through alternate racial ids", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ knownSpells[357214] = true
+ spellTextures[357214] = "tail-swipe-icon"
+
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return makeViewersConfig({ { kind = "spell", ids = { { spellId = 368970 } } } })
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local vs = ExtraIcons._viewers.utility
+ assert.are.equal(357214, vs.iconPool[1].spellId)
+ assert.are.equal("tail-swipe-icon", vs.iconPool[1].Icon:GetTexture())
+ end)
+
it("skips spell cooldown swipe when only on GCD", function()
local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
utilityIconChild.GetSpellID = function() return 1 end
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 402b2f0f..a8756ebb 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -79,6 +79,11 @@ describe("ExtraIconsOptions data helpers", function()
assert.is_true(ExtraIconsOptions._isRacialPresent(viewers, 33697))
end)
+ it("finds racial by any candidate id", function()
+ local viewers = { utility = { { kind = "spell", ids = { 368970 } } }, main = {} }
+ assert.is_true(ExtraIconsOptions._isRacialPresent(viewers, { 357214, 368970 }))
+ end)
+
it("returns false when absent", function()
local viewers = { utility = { { kind = "spell", ids = { 59752 } } }, main = {} }
assert.is_false(ExtraIconsOptions._isRacialPresent(viewers, 33697))
@@ -269,6 +274,15 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.same({ 59752 }, profile.extraIcons.viewers.utility[1].ids)
end)
+ it("adds all candidate ids for racials with alternate spell ids", function()
+ local profile = { extraIcons = { viewers = { utility = {}, main = {} } } }
+
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, "utility", nil, { 357214, 368970 })
+
+ assert.are.equal(1, #profile.extraIcons.viewers.utility)
+ assert.are.same({ 357214, 368970 }, profile.extraIcons.viewers.utility[1].ids)
+ end)
+
it("removes a persisted racial row when toggled", function()
local profile = { extraIcons = { viewers = { utility = { { kind = "spell", ids = { 59752 } } }, main = {} } } }
@@ -586,6 +600,12 @@ describe("ExtraIconsOptions data helpers", function()
assert.is_true(ExtraIconsOptions._isCurrentRacialEntry({ kind = "spell", ids = { 59752 } }))
end)
+ it("returns true for alternate ids of the current player's racial", function()
+ _G.UnitRace = function() return "Dracthyr", "Dracthyr", 70 end
+
+ assert.is_true(ExtraIconsOptions._isCurrentRacialEntry({ kind = "spell", ids = { 368970 } }))
+ end)
+
it("returns false for non-racial entries", function()
assert.is_false(ExtraIconsOptions._isCurrentRacialEntry({ stackKey = "trinket1" }))
end)
@@ -719,6 +739,21 @@ describe("ExtraIconsOptions data helpers", function()
assert.are.equal(58984, rows[#rows].spellId)
end)
+ it("matches Tail Swipe from the Dracthyr race file token", function()
+ local viewers = {
+ utility = {},
+ main = {},
+ }
+
+ _G.UnitRace = function() return "Dracthyr", "Dracthyr", 70 end
+
+ local rows = ExtraIconsOptions._buildViewerRows(viewers, "utility")
+
+ assert.are.equal("racialPlaceholder", rows[#rows].rowType)
+ assert.are.equal(357214, rows[#rows].spellId)
+ assert.are.same({ 357214, 368970 }, rows[#rows].displayEntry.ids)
+ end)
+
it("does not synthesize a racial placeholder when UnitRace has no matching race file token", function()
local viewers = {
utility = {},
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 1716f790..388db2c6 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -62,6 +62,8 @@ local ACTION_BUTTON_TEXTURES = {
local BUILTIN_STACK_SET = {}
local BUILTIN_EQUIP_SLOTS = {}
local RACIAL_SPELL_IDS = {}
+local function getRacialSpellIds(racial) return racial.spellIds or { racial.spellId } end
+local function getSpellId(id) return type(id) == "table" and id.spellId or id end
for _, key in ipairs(BUILTIN_STACK_ORDER) do
BUILTIN_STACK_SET[key] = true
end
@@ -71,7 +73,9 @@ for _, stack in pairs(BUILTIN_STACKS) do
end
end
for _, racial in pairs(RACIAL_ABILITIES) do
- RACIAL_SPELL_IDS[racial.spellId] = true
+ for _, spellId in ipairs(getRacialSpellIds(racial)) do
+ RACIAL_SPELL_IDS[spellId] = true
+ end
end
local ExtraIconsOptions = ns.ExtraIconsOptions or {}
@@ -121,8 +125,25 @@ local function getEntrySpellId(entry)
if not (entry and entry.kind == "spell" and entry.ids and entry.ids[1]) then
return nil
end
- local first = entry.ids[1]
- return type(first) == "table" and first.spellId or first
+ return getSpellId(entry.ids[1])
+end
+
+local function entryHasSpellId(entry, spellId)
+ if not (spellId and entry and entry.kind == "spell" and entry.ids) then
+ return false
+ end
+ for _, id in ipairs(entry.ids) do
+ if getSpellId(id) == spellId then return true end
+ end
+ return false
+end
+
+local function entryHasAnySpellId(entry, spellIds)
+ if type(spellIds) ~= "table" then return entryHasSpellId(entry, spellIds) end
+ for _, spellId in ipairs(spellIds) do
+ if entryHasSpellId(entry, spellId) then return true end
+ end
+ return false
end
local function getItemIdFromEntry(entry) return type(entry) == "table" and (entry.itemID or entry.itemId) or entry end
@@ -150,10 +171,10 @@ local function appendViewerEntry(viewers, viewerKey, entry)
entries[#entries + 1] = entry
end
-local function getCurrentRacialSpellId()
+local function getCurrentRacialSpellIds()
local _, raceFile = UnitRace("player")
local racial = raceFile and RACIAL_ABILITIES[raceFile] or nil
- return racial and racial.spellId or nil
+ return racial and getRacialSpellIds(racial) or nil
end
local function getItemDisplayName(itemId)
@@ -297,18 +318,30 @@ function ExtraIconsOptions._shouldShowBuiltinStackRow(stackKey)
return spellId ~= nil
end
-function ExtraIconsOptions._isRacialPresent(viewers, spellId) return ExtraIconsOptions._findDuplicateEntry(viewers, buildEntry("spell", { spellId })) ~= nil end
+function ExtraIconsOptions._isRacialPresent(viewers, spellIds)
+ return findViewerEntry(viewers, function(entry)
+ return entryHasAnySpellId(entry, spellIds)
+ end) ~= nil
+end
-function ExtraIconsOptions._isCurrentRacialEntry(entry) return getEntrySpellId(entry) == getCurrentRacialSpellId() end
+function ExtraIconsOptions._isCurrentRacialEntry(entry) return entryHasAnySpellId(entry, getCurrentRacialSpellIds()) end
function ExtraIconsOptions._isRacialForCurrentPlayer(entry)
- local spellId = getEntrySpellId(entry)
- if not spellId then
+ if not (entry and entry.kind == "spell" and entry.ids) then
return true
end
- local currentSpellId = getCurrentRacialSpellId()
- return not currentSpellId or spellId == currentSpellId or not RACIAL_SPELL_IDS[spellId]
+ local hasRacialSpell = false
+ for _, id in ipairs(entry.ids) do
+ local spellId = getSpellId(id)
+ if RACIAL_SPELL_IDS[spellId] then
+ hasRacialSpell = true
+ break
+ end
+ end
+
+ local currentSpellIds = getCurrentRacialSpellIds()
+ return not currentSpellIds or entryHasAnySpellId(entry, currentSpellIds) or not hasRacialSpell
end
local function shouldShowEntryRow(entry) return ExtraIconsOptions._isRacialForCurrentPlayer(entry) and (not entry.stackKey or ExtraIconsOptions._shouldShowBuiltinStackRow(entry.stackKey)) end
@@ -374,9 +407,11 @@ function ExtraIconsOptions._addStackKey(profile, viewerKey, stackKey)
if not ExtraIconsOptions._isStackKeyPresent(viewers, stackKey) then appendViewerEntry(viewers, viewerKey, { stackKey = stackKey }) end
end
-function ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+function ExtraIconsOptions._addRacial(profile, viewerKey, spellIds)
local viewers = profile.extraIcons.viewers
- if not ExtraIconsOptions._isRacialPresent(viewers, spellId) then appendViewerEntry(viewers, viewerKey, buildEntry("spell", { spellId })) end
+ if not ExtraIconsOptions._isRacialPresent(viewers, spellIds) then
+ appendViewerEntry(viewers, viewerKey, buildEntry("spell", type(spellIds) == "table" and spellIds or { spellIds }))
+ end
end
function ExtraIconsOptions._addCustomEntry(profile, viewerKey, kind, ids)
@@ -406,13 +441,13 @@ function ExtraIconsOptions._toggleBuiltinRow(profile, viewerKey, index, stackKey
if entry then entry.disabled = not entry.disabled and true or nil end
end
-function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellId)
+function ExtraIconsOptions._toggleCurrentRacialRow(profile, viewerKey, index, spellIds)
if index then
ExtraIconsOptions._removeEntry(profile, viewerKey, index)
return
end
- if spellId then
- ExtraIconsOptions._addRacial(profile, viewerKey, spellId)
+ if spellIds then
+ ExtraIconsOptions._addRacial(profile, viewerKey, spellIds)
end
end
@@ -521,6 +556,7 @@ local function makeRowData(rowType, viewerKey, displayEntry, index)
index = index,
stackKey = displayEntry.stackKey,
spellId = getEntrySpellId(displayEntry),
+ spellIds = displayEntry.kind == "spell" and displayEntry.ids or nil,
displayEntry = displayEntry,
isBuiltin = displayEntry.stackKey ~= nil,
isCurrentRacial = rowType == "racialPlaceholder" or ExtraIconsOptions._isCurrentRacialEntry(displayEntry),
@@ -564,9 +600,9 @@ function ExtraIconsOptions._buildViewerRows(viewers, viewerKey)
end
if viewerKey == DEFAULT_SPECIAL_VIEWER then
- local racialSpellId = getCurrentRacialSpellId()
- if racialSpellId and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellId) then
- rows[#rows + 1] = makeRowData("racialPlaceholder", viewerKey, buildEntry("spell", { racialSpellId }))
+ local racialSpellIds = getCurrentRacialSpellIds()
+ if racialSpellIds and not ExtraIconsOptions._isRacialPresent(viewers, racialSpellIds) then
+ rows[#rows + 1] = makeRowData("racialPlaceholder", viewerKey, buildEntry("spell", racialSpellIds))
end
end
@@ -663,7 +699,7 @@ local function getDeleteAction(rowData, displayEntry, controlsDisabled)
if rowData.isCurrentRacial and rowData.isPlaceholder then
return makeAction("+", ACTION_BUTTON_TEXTURES.show, not controlsDisabled, L["ADD_ENTRY"], profileAction(function(profile)
- ExtraIconsOptions._toggleCurrentRacialRow(profile, rowData.viewerKey, nil, rowData.spellId)
+ ExtraIconsOptions._toggleCurrentRacialRow(profile, rowData.viewerKey, nil, rowData.spellIds)
end))
end
From 92e5b8d6bd564fec9231dfd8060a7baee968301c Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 28 Apr 2026 02:19:02 +1000
Subject: [PATCH 37/53] Add stack and charge count.
---
Defaults.lua | 4 ++
Locales/en.lua | 4 ++
Modules/ExtraIcons.lua | 45 +++++++++++++++-
Tests/Modules/ExtraIcons_spec.lua | 80 +++++++++++++++++++++++++++++
Tests/UI/ExtraIconsOptions_spec.lua | 6 ++-
UI/ExtraIconsOptions.lua | 24 +++++++++
6 files changed, 160 insertions(+), 3 deletions(-)
diff --git a/Defaults.lua b/Defaults.lua
index 58845986..5e80921f 100644
--- a/Defaults.lua
+++ b/Defaults.lua
@@ -128,6 +128,8 @@ local _, ns = ...
---@class ECM_ExtraIconsConfig Extra icons configuration.
---@field enabled boolean Whether extra icons are enabled.
+---@field showStackCount boolean Whether to show item stack counts.
+---@field showCharges boolean Whether to show spell charges.
---@field viewers table Per-viewer ordered icon lists.
---@class ECM_TickMark Tick mark definition.
@@ -320,6 +322,8 @@ local defaults = {
},
extraIcons = {
enabled = true,
+ showStackCount = true,
+ showCharges = true,
viewers = {
utility = {
{ stackKey = "trinket1" },
diff --git a/Locales/en.lua b/Locales/en.lua
index aff6a065..065063e5 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -239,6 +239,10 @@ L["TICK_COUNT"] = "%s - %d tick mark(s) configured."
L["ENABLE_EXTRA_ICONS"] = "Enable extra icons"
L["ENABLE_EXTRA_ICONS_DESC"] =
"Display icons for equipped on-use trinkets, select consumables, and custom spells or items to the right of cooldown viewers."
+L["SHOW_STACK_COUNT"] = "Show stack count"
+L["SHOW_STACK_COUNT_DESC"] = "Display item stack counts on extra icons."
+L["SHOW_CHARGES"] = "Show charges"
+L["SHOW_CHARGES_DESC"] = "Display spell charges on extra icons."
L["UTILITY_VIEWER_ICONS"] = "Utility Viewer Icons"
L["MAIN_VIEWER_ICONS"] = "Main Viewer Icons"
L["UTILITY_VIEWER_SHORT"] = "Utility"
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 5c12b6c8..1749bac8 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -217,6 +217,12 @@ local function createIcon(parent, size, borderScale)
icon.Border:SetPoint("CENTER")
icon.Border:SetSize(size * borderScale[1], size * borderScale[2])
+ icon.Count = icon:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
+ icon.Count:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -2, 2)
+ icon.Count:SetJustifyH("RIGHT")
+ icon.Count:SetJustifyV("BOTTOM")
+ icon.Count:Hide()
+
icon.Shadow = icon:CreateTexture(nil, "OVERLAY")
icon.Shadow:SetAtlas("UI-CooldownManager-OORshadow")
icon.Shadow:SetAllPoints()
@@ -262,6 +268,38 @@ local function updateIconCooldown(icon)
end
end
+local function setIconCountText(icon, text)
+ if text ~= nil then
+ icon.Count:SetText(tostring(text))
+ icon.Count:Show()
+ else
+ icon.Count:SetText(nil)
+ icon.Count:Hide()
+ end
+end
+
+local function updateIconCountText(icon, config)
+ if not icon.Count then return end
+
+ if icon.itemId and (not config or config.showStackCount ~= false) then
+ local count = C_Item.GetItemCount(icon.itemId)
+ if count and count > 1 then
+ setIconCountText(icon, count)
+ return
+ end
+ end
+
+ if icon.spellId and (not config or config.showCharges ~= false) then
+ local charges = C_Spell.GetSpellCharges(icon.spellId)
+ if charges and charges.maxCharges and charges.maxCharges > 1 and charges.currentCharges ~= nil then
+ setIconCountText(icon, charges.currentCharges)
+ return
+ end
+ end
+
+ setIconCountText(icon, nil)
+end
+
--- Caches and returns the cooldown number font from a sibling Blizzard icon.
local function getSiblingFont(viewer)
local cached = viewer.__ecmCDFont
@@ -351,7 +389,7 @@ function ExtraIcons:GetMainViewerAnchor()
return _G[BLIZZ_KEY.main]
end
-function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX)
+function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOffsetX, moduleConfig)
local blizzFrame = _G[BLIZZ_KEY[viewerKey]]
local vs = self._viewers[viewerKey]
if not vs then return false end
@@ -427,6 +465,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
icon:Show()
updateIconCooldown(icon)
+ updateIconCountText(icon, moduleConfig)
if fontPath and fontSize then
local region = icon.Cooldown:GetRegions()
@@ -465,7 +504,7 @@ function ExtraIcons:UpdateLayout(why)
for _, v in ipairs(VIEWERS) do
local entries = viewers and viewers[v.key] or {}
- if self:_updateSingleViewer(v.key, entries, isEditing, offsets[v.key]) then
+ if self:_updateSingleViewer(v.key, entries, isEditing, offsets[v.key], moduleConfig) then
anyPlaced = true
end
end
@@ -491,11 +530,13 @@ function ExtraIcons:Refresh(why, force)
if not self._viewers then return false end
local refreshed = false
+ local moduleConfig = self.GetModuleConfig and self:GetModuleConfig() or nil
for _, vs in pairs(self._viewers) do
if vs.container and vs.container:IsShown() then
for _, icon in ipairs(vs.iconPool) do
if icon:IsShown() and (icon.slotId or icon.itemId or icon.spellId) then
updateIconCooldown(icon)
+ updateIconCountText(icon, moduleConfig)
end
end
refreshed = true
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 7dd5f088..ab56c479 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -372,6 +372,25 @@ describe("ExtraIcons real source", function()
end
return texture
end
+ frame.CreateFontString = function()
+ local fontString = TestHelpers.makeRegion("FontString")
+ fontString.SetPoint = function() end
+ fontString.SetJustifyH = function() end
+ fontString.SetJustifyV = function() end
+ fontString.SetText = function(self, text)
+ self.__text = text
+ end
+ fontString.Show = function(self)
+ self.__shown = true
+ end
+ fontString.Hide = function(self)
+ self.__shown = false
+ end
+ fontString.IsShown = function(self)
+ return self.__shown == true
+ end
+ return fontString
+ end
frame.SetAllPoints = function() end
frame.SetDrawEdge = function() end
frame.SetDrawSwipe = function() end
@@ -1340,6 +1359,67 @@ describe("ExtraIcons real source", function()
assert.same({ 100, 60 }, ExtraIcons._viewers.utility.iconPool[1].Cooldown.__cooldown)
end)
+ it("shows item stack counts when enabled", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 5
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ local config = makeViewersConfig({ { stackKey = "healthstones" } })
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return config
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local count = ExtraIcons._viewers.utility.iconPool[1].Count
+ assert.are.equal("5", count.__text)
+ assert.is_true(count:IsShown())
+
+ config.showStackCount = false
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ assert.is_nil(count.__text)
+ assert.is_false(count:IsShown())
+ end)
+
+ it("shows spell charges when enabled and refreshes them", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ knownSpells[59752] = true
+ spellTextures[59752] = "racial-icon"
+ spellCharges[59752] = { currentCharges = 2, maxCharges = 3 }
+
+ local config = makeViewersConfig({ { kind = "spell", ids = { { spellId = 59752 } } } })
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return config
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("test"))
+ local count = ExtraIcons._viewers.utility.iconPool[1].Count
+ assert.are.equal("2", count.__text)
+ assert.is_true(count:IsShown())
+
+ spellCharges[59752] = { currentCharges = 1, maxCharges = 3 }
+ assert.is_true(ExtraIcons:Refresh("test"))
+ assert.are.equal("1", count.__text)
+
+ config.showCharges = false
+ assert.is_true(ExtraIcons:Refresh("test"))
+ assert.is_nil(count.__text)
+ assert.is_false(count:IsShown())
+ end)
+
it("refreshes cooldowns for visible icons across viewers", function()
ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
local vs = ExtraIcons._viewers.utility
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index a8756ebb..2ab3c867 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -849,6 +849,8 @@ describe("ExtraIconsOptions settings page", function()
profile.extraIcons = {
enabled = true,
+ showStackCount = true,
+ showCharges = true,
viewers = {
utility = {},
main = {},
@@ -893,9 +895,11 @@ describe("ExtraIconsOptions settings page", function()
assert.is_table(opts._draftStates)
assert.are.equal("checkbox", getRow("enabled").type)
+ assert.are.equal("checkbox", getRow("showStackCount").type)
+ assert.are.equal("checkbox", getRow("showCharges").type)
assert.are.equal("sectionList", getRow("viewers").type)
assert.are.equal(4, getRow("viewers").footerSpacing)
- assert.are.equal(2, #capturedPage.rows)
+ assert.are.equal(4, #capturedPage.rows)
end)
it("builds utility and main sections with placeholder rows and footers", function()
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 388db2c6..a37c00af 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -884,6 +884,30 @@ ExtraIconsOptions.pages = {
ctx.page:Refresh()
end,
},
+ {
+ id = "showStackCount",
+ type = "checkbox",
+ path = "showStackCount",
+ name = L["SHOW_STACK_COUNT"],
+ tooltip = L["SHOW_STACK_COUNT_DESC"],
+ disabled = isDisabled,
+ onSet = function(ctx)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ ctx.page:Refresh()
+ end,
+ },
+ {
+ id = "showCharges",
+ type = "checkbox",
+ path = "showCharges",
+ name = L["SHOW_CHARGES"],
+ tooltip = L["SHOW_CHARGES_DESC"],
+ disabled = isDisabled,
+ onSet = function(ctx)
+ ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
+ ctx.page:Refresh()
+ end,
+ },
{
id = "viewers",
type = "sectionList",
From d85c7a847b7d64a4d33e56233da65ad7b85b0bfb Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 28 Apr 2026 16:51:10 +1000
Subject: [PATCH 38/53] allow font to be changed for charges and stack.
---
Defaults.lua | 4 ++
Locales/en.lua | 4 +-
Modules/ExtraIcons.lua | 2 +
Tests/Modules/ExtraIcons_spec.lua | 48 +++++++++++++++++++++++
Tests/UI/ExtraIconsOptions_spec.lua | 61 +++++++++++++++++++++++++----
UI/ExtraIconsOptions.lua | 5 +++
6 files changed, 115 insertions(+), 9 deletions(-)
diff --git a/Defaults.lua b/Defaults.lua
index 5e80921f..86b7ec5d 100644
--- a/Defaults.lua
+++ b/Defaults.lua
@@ -130,6 +130,9 @@ local _, ns = ...
---@field enabled boolean Whether extra icons are enabled.
---@field showStackCount boolean Whether to show item stack counts.
---@field showCharges boolean Whether to show spell charges.
+---@field overrideFont boolean|nil Whether stack/charge counts override global font settings.
+---@field font string|nil Font face override for stack/charge counts.
+---@field fontSize number|nil Font size override for stack/charge counts.
---@field viewers table Per-viewer ordered icon lists.
---@class ECM_TickMark Tick mark definition.
@@ -324,6 +327,7 @@ local defaults = {
enabled = true,
showStackCount = true,
showCharges = true,
+ overrideFont = false,
viewers = {
utility = {
{ stackKey = "trinket1" },
diff --git a/Locales/en.lua b/Locales/en.lua
index 065063e5..ba4ca710 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -240,9 +240,9 @@ L["ENABLE_EXTRA_ICONS"] = "Enable extra icons"
L["ENABLE_EXTRA_ICONS_DESC"] =
"Display icons for equipped on-use trinkets, select consumables, and custom spells or items to the right of cooldown viewers."
L["SHOW_STACK_COUNT"] = "Show stack count"
-L["SHOW_STACK_COUNT_DESC"] = "Display item stack counts on extra icons."
+L["SHOW_STACK_COUNT_DESC"] = "Display stack counts on items."
L["SHOW_CHARGES"] = "Show charges"
-L["SHOW_CHARGES_DESC"] = "Display spell charges on extra icons."
+L["SHOW_CHARGES_DESC"] = "Display spell charges on icons."
L["UTILITY_VIEWER_ICONS"] = "Utility Viewer Icons"
L["MAIN_VIEWER_ICONS"] = "Main Viewer Icons"
L["UTILITY_VIEWER_SHORT"] = "Utility"
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index 1749bac8..cc1e9ce8 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -4,6 +4,7 @@
local _, ns = ...
local BarMixin = ns.BarMixin
+local FrameUtil = ns.FrameUtil
local ExtraIcons = ns.Addon:NewModule("ExtraIcons")
ns.Addon.ExtraIcons = ExtraIcons
@@ -280,6 +281,7 @@ end
local function updateIconCountText(icon, config)
if not icon.Count then return end
+ FrameUtil.ApplyFont(icon.Count, nil, config)
if icon.itemId and (not config or config.showStackCount ~= false) then
local count = C_Item.GetItemCount(icon.itemId)
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index ab56c479..388ba0c1 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -196,6 +196,7 @@ describe("ExtraIcons real source", function()
local spellCooldowns
local spellCooldownInfos
local spellCharges
+ local applyFontCalls
local ratedMap
setup(function()
@@ -237,6 +238,7 @@ describe("ExtraIcons real source", function()
spellCooldowns = {}
spellCooldownInfos = {}
spellCharges = {}
+ applyFontCalls = {}
ratedMap = false
_G.C_SpellBook = {
IsSpellKnown = function(spellId)
@@ -267,6 +269,15 @@ describe("ExtraIcons real source", function()
UnregisterFrame = function() end,
RequestLayout = function() end,
},
+ FrameUtil = {
+ ApplyFont = function(fontString, globalConfig, moduleConfig)
+ applyFontCalls[#applyFontCalls + 1] = {
+ fontString = fontString,
+ globalConfig = globalConfig,
+ moduleConfig = moduleConfig,
+ }
+ end,
+ },
}
TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
@@ -1380,6 +1391,8 @@ describe("ExtraIcons real source", function()
local count = ExtraIcons._viewers.utility.iconPool[1].Count
assert.are.equal("5", count.__text)
assert.is_true(count:IsShown())
+ assert.are.equal(count, applyFontCalls[#applyFontCalls].fontString)
+ assert.are.equal(config, applyFontCalls[#applyFontCalls].moduleConfig)
config.showStackCount = false
assert.is_true(ExtraIcons:UpdateLayout("test"))
@@ -1387,6 +1400,41 @@ describe("ExtraIcons real source", function()
assert.is_false(count:IsShown())
end)
+ it("reapplies count font config during layout updates", function()
+ local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
+ utilityIconChild.GetSpellID = function() return 1 end
+ UtilityCooldownViewer.childXPadding = 0
+ UtilityCooldownViewer.iconScale = 1
+ UtilityCooldownViewer._children = { utilityIconChild }
+ UtilityCooldownViewer:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
+
+ itemCounts[ns.Constants.HEALTHSTONE_ITEM_ID] = 5
+ itemIconsByID[ns.Constants.HEALTHSTONE_ITEM_ID] = "healthstone"
+
+ local config = makeViewersConfig({ { stackKey = "healthstones" } })
+ ExtraIcons.InnerFrame = ExtraIcons:CreateFrame()
+ ExtraIcons.GetModuleConfig = function()
+ return config
+ end
+
+ assert.is_true(ExtraIcons:UpdateLayout("initial"))
+ local count = ExtraIcons._viewers.utility.iconPool[1].Count
+ local previousApplyCount = #applyFontCalls
+
+ config.overrideFont = true
+ config.font = "Expressway"
+ config.fontSize = 18
+ assert.is_true(ExtraIcons:UpdateLayout("OptionsChanged"))
+
+ assert.is_true(#applyFontCalls > previousApplyCount)
+ assert.are.equal(count, applyFontCalls[#applyFontCalls].fontString)
+ assert.are.equal(config, applyFontCalls[#applyFontCalls].moduleConfig)
+ assert.is_true(applyFontCalls[#applyFontCalls].moduleConfig.overrideFont)
+ assert.are.equal("Expressway", applyFontCalls[#applyFontCalls].moduleConfig.font)
+ assert.are.equal(18, applyFontCalls[#applyFontCalls].moduleConfig.fontSize)
+ assert.are.equal("5", count.__text)
+ end)
+
it("shows spell charges when enabled and refreshes them", function()
local utilityIconChild = TestHelpers.makeFrame({ shown = true, width = 18, height = 18 })
utilityIconChild.GetSpellID = function() return 1 end
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index 2ab3c867..f0b89ae2 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -42,6 +42,9 @@ describe("ExtraIconsOptions data helpers", function()
GetIsDisabledDelegate = function() return function() return false end end,
CreateModuleEnabledHandler = function() return function() end end,
MakeConfirmDialog = function() return {} end,
+ CreateFontOverrideRow = function()
+ return { type = "fontOverride" }
+ end,
}
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ExtraIconsOptions = ns.ExtraIconsOptions
@@ -774,7 +777,7 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns, capturedPage, registeredPage, refreshCalls, scheduledReasons, previewCalls
+ local profile, defaults, SB, ns, capturedPage, registeredPage, refreshCalls, scheduledReasons, previewCalls, settings
local function getRow(rowId)
local rows = assert(capturedPage and capturedPage.rows)
@@ -851,6 +854,7 @@ describe("ExtraIconsOptions settings page", function()
enabled = true,
showStackCount = true,
showCharges = true,
+ overrideFont = false,
viewers = {
utility = {},
main = {},
@@ -865,11 +869,13 @@ describe("ExtraIconsOptions settings page", function()
previewCalls[#previewCalls + 1] = active
end
- TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
- capturedPage = ns.ExtraIconsOptions.pages[1]
- local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
- registeredPage = page
- ns.ExtraIconsOptions.SetRegisteredPage(page)
+ settings = TestHelpers.CollectSettings(function()
+ TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
+ capturedPage = ns.ExtraIconsOptions.pages[1]
+ local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
+ registeredPage = page
+ ns.ExtraIconsOptions.SetRegisteredPage(page)
+ end)
ns.ExtraIconsOptions.EnsureItemLoadFrame()
registeredPage.Refresh = function()
refreshCalls[#refreshCalls + 1] = registeredPage._category
@@ -897,9 +903,24 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("checkbox", getRow("enabled").type)
assert.are.equal("checkbox", getRow("showStackCount").type)
assert.are.equal("checkbox", getRow("showCharges").type)
+ assert.are.equal("fontOverride", getRow("fontOverride").type)
assert.are.equal("sectionList", getRow("viewers").type)
assert.are.equal(4, getRow("viewers").footerSpacing)
- assert.are.equal(4, #capturedPage.rows)
+ assert.are.equal(5, #capturedPage.rows)
+ assert.are.equal("showCharges", capturedPage.rows[3].id)
+ assert.are.equal("fontOverride", capturedPage.rows[4].id)
+ assert.are.equal("viewers", capturedPage.rows[5].id)
+ end)
+
+ it("schedules layout when count font settings change", function()
+ settings["ECM_extraIcons_overrideFont"]:SetValue(true)
+ settings["ECM_extraIcons_font"]:SetValue("Expressway")
+ settings["ECM_extraIcons_fontSize"]:SetValue(18)
+
+ assert.is_true(profile.extraIcons.overrideFont)
+ assert.are.equal("Expressway", profile.extraIcons.font)
+ assert.are.equal(18, profile.extraIcons.fontSize)
+ assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
end)
it("builds utility and main sections with placeholder rows and footers", function()
@@ -1066,6 +1087,32 @@ describe("ExtraIconsOptions settings page", function()
assert.are.same({ "OptionsChanged" }, scheduledReasons)
end)
+ it("keeps footer add disabled until the draft ID resolves", function()
+ _G.C_Spell = {
+ GetSpellName = function(spellId)
+ return spellId == 12345 and "Test Spell" or nil
+ end,
+ GetSpellTexture = function(spellId)
+ return spellId == 12345 and "spell-tex" or nil
+ end,
+ }
+
+ local footer = assert(getSection("main")).footer
+ assert.is_false(getTrailerValue(footer, "submitEnabled"))
+ assert.is_false(footer.onSubmit())
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+
+ footer.onTextChanged("99999")
+ footer = assert(getSection("main")).footer
+ assert.is_false(getTrailerValue(footer, "submitEnabled"))
+ assert.is_false(footer.onSubmit())
+ assert.are.equal(0, #profile.extraIcons.viewers.main)
+
+ footer.onTextChanged("12345")
+ footer = assert(getSection("main")).footer
+ assert.is_true(getTrailerValue(footer, "submitEnabled"))
+ end)
+
it("shows pending item previews and refreshes when item data arrives", function()
local itemNames = {}
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index a37c00af..45d4111a 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -908,6 +908,11 @@ ExtraIconsOptions.pages = {
ctx.page:Refresh()
end,
},
+ (function()
+ local row = ns.OptionUtil.CreateFontOverrideRow(isDisabled)
+ row.id = "fontOverride"
+ return row
+ end)(),
{
id = "viewers",
type = "sectionList",
From 47c36d2b85f1b5a31956cd8c3bade5a22def072e Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Tue, 28 Apr 2026 21:43:21 +1000
Subject: [PATCH 39/53] Fix double defaults button Fix font override for extra
icons
Co-authored-by: Copilot
---
.../LibLSMSettingsWidgets.lua | 4 +-
.../Tests/LibLSMSettingsWidgets_spec.lua | 8 +-
Libs/LibSettingsBuilder/Controls/Base.lua | 22 ++-
Libs/LibSettingsBuilder/Core.lua | 2 +-
.../Primitives/BlizzardControls.lua | 34 ++--
.../Tests/Controls_spec.lua | 146 ++++++++++++++++
Locales/en.lua | 3 -
Modules/ExternalBars.lua | 137 +++++++++------
Modules/ExtraIcons.lua | 12 +-
Tests/Modules/ExternalBars_spec.lua | 157 +++++++++++-------
Tests/Modules/ExtraIcons_spec.lua | 18 +-
Tests/UI/PowerBarTickMarksOptions_spec.lua | 53 +-----
UI/PowerBarTickMarksOptions.lua | 48 ------
docs/ExternalBars.md | 21 +--
14 files changed, 406 insertions(+), 259 deletions(-)
diff --git a/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua b/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua
index 36018eab..98ee8394 100644
--- a/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua
+++ b/Libs/LibLSMSettingsWidgets/LibLSMSettingsWidgets.lua
@@ -1,7 +1,7 @@
-- LibLSMSettingsWidgets: LibSharedMedia picker widgets for the WoW Settings API.
-- Provides font and texture picker templates with live previews.
-local MAJOR, MINOR = "LibLSMSettingsWidgets-1.0", 1
+local MAJOR, MINOR = "LibLSMSettingsWidgets-1.0", 2
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end
@@ -92,7 +92,7 @@ local function initPicker(self, initializer)
SettingsListElementMixin.Init(self, initializer)
local data = initializer:GetData() or {}
- self.setting = initializer:GetSetting() or data.setting
+ self.setting = data.setting or (initializer.GetSetting and initializer:GetSetting()) or nil
if data.name and self.Text then
self.Text:SetText(data.name)
diff --git a/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua b/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua
index 832563ef..a909f1d0 100644
--- a/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua
+++ b/Libs/LibLSMSettingsWidgets/Tests/LibLSMSettingsWidgets_spec.lua
@@ -88,10 +88,14 @@ describe("LibLSMSettingsWidgets", function()
GetValue = function() return "TestFont" end,
SetValue = function() end,
}
+ local staleSetting = {
+ GetValue = function() return "chain" end,
+ SetValue = function() end,
+ }
local initializer = {
GetData = function() return { name = "Test", setting = setting } end,
- GetSetting = function() return setting end,
+ GetSetting = function() return staleSetting end,
}
local picker = {
@@ -120,6 +124,8 @@ describe("LibLSMSettingsWidgets", function()
picker.SetEnabled = mixin.SetEnabled
mixin.Init(picker, initializer)
+ assert.are.same(setting, picker.setting)
+
-- Init should have bridged SetEnabled onto the initializer
assert.is_function(initializer.SetEnabled)
diff --git a/Libs/LibSettingsBuilder/Controls/Base.lua b/Libs/LibSettingsBuilder/Controls/Base.lua
index 7343b891..f81efc9c 100644
--- a/Libs/LibSettingsBuilder/Controls/Base.lua
+++ b/Libs/LibSettingsBuilder/Controls/Base.lua
@@ -63,15 +63,16 @@ function lib.Dropdown(self, spec)
setting._optionsGen = optionsGenerator
local initializer = Settings.CreateDropdown(category, setting, optionsGenerator, spec.tooltip)
+ initializer._lsbData = {
+ _lsbKind = "dropdown",
+ setting = setting,
+ values = spec.values,
+ name = spec.name,
+ tooltip = spec.tooltip,
+ }
if spec.scrollHeight then
- initializer._lsbData = {
- _lsbKind = "scrollDropdown",
- setting = setting,
- values = spec.values,
- scrollHeight = spec.scrollHeight,
- name = spec.name,
- tooltip = spec.tooltip,
- }
+ initializer._lsbData._lsbKind = "scrollDropdown"
+ initializer._lsbData.scrollHeight = spec.scrollHeight
if initializer.SetSetting then
initializer:SetSetting(setting)
end
@@ -89,8 +90,10 @@ function lib.Dropdown(self, spec)
if type(spec.values) == "function" and not initializer._lsbRefreshFrame then
initializer._lsbRefreshFrame = function(frame)
- if frame and frame.InitDropdown then
+ if frame and frame.InitDropdown and frame.lsbData and frame.lsbData._lsbKind == "scrollDropdown" then
frame:InitDropdown(initializer)
+ elseif frame and frame.RefreshDropdownText then
+ frame:RefreshDropdownText()
elseif frame and frame.SetValue and setting.GetValue then
frame:SetValue(setting:GetValue())
end
@@ -213,6 +216,7 @@ function lib.Custom(self, spec)
local setting, category = internal.makeProxySetting(self, spec, spec.varType or Settings.VarType.String, "")
local initializer = Settings.CreateElementInitializer(spec.template, {
name = spec.name,
+ setting = setting,
tooltip = spec.tooltip,
})
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index 0910cf44..f309f2b0 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -51,7 +51,7 @@
---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition.
---@field sections LibSettingsBuilderSectionConfig[]|nil Gets the optional section definitions registered under the root category.
-local MAJOR, MINOR = "LibSettingsBuilder-1.0", 4
+local MAJOR, MINOR = "LibSettingsBuilder-1.0", 5
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
return
diff --git a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
index 98207692..5e3e86e2 100644
--- a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
+++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
@@ -13,16 +13,19 @@ local copyMixin = internal.copyMixin
local getInitializerData = internal.getInitializerData
local getOrderedValueEntries = internal.getOrderedValueEntries
-local ScrollDropdownMethods = {}
+local DropdownMethods = {}
-function ScrollDropdownMethods:GetSetting()
+function DropdownMethods:GetSetting()
+ if self.lsbData and self.lsbData.setting then
+ return self.lsbData.setting
+ end
if self.initializer and self.initializer.GetSetting then
return self.initializer:GetSetting()
end
- return self.lsbData and self.lsbData.setting or nil
+ return nil
end
-function ScrollDropdownMethods:RefreshDropdownText(value)
+function DropdownMethods:RefreshDropdownText(value)
local dropdown = self.Control and self.Control.Dropdown
if not dropdown then
return
@@ -47,11 +50,14 @@ function ScrollDropdownMethods:RefreshDropdownText(value)
end
end
-function ScrollDropdownMethods:SetValue(value)
+function DropdownMethods:SetValue(value)
+ if self._lsbOriginalSetValue then
+ self:_lsbOriginalSetValue(value)
+ end
self:RefreshDropdownText(value)
end
-function ScrollDropdownMethods:InitDropdown()
+function DropdownMethods:InitDropdown()
local setting = self:GetSetting()
local data = self.lsbData or {}
local scrollHeight = data.scrollHeight or 200
@@ -85,22 +91,26 @@ function ScrollDropdownMethods:InitDropdown()
self:RefreshDropdownText()
end
-local function configureScrollDropdownFrame(frame, initializer)
+local function configureDropdownFrame(frame, initializer, data)
if not frame._lsbOriginalSetValue then
frame._lsbOriginalSetValue = frame.SetValue
end
- copyMixin(frame, ScrollDropdownMethods)
+ copyMixin(frame, DropdownMethods)
frame.initializer = initializer
- frame.lsbData = getInitializerData(initializer) or {}
+ frame.lsbData = data or {}
initializer._lsbActiveFrame = frame
- frame:InitDropdown()
+ if frame.lsbData._lsbKind == "scrollDropdown" then
+ frame:InitDropdown()
+ else
+ frame:RefreshDropdownText()
+ end
end
if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownControlMixin then
hooksecurefunc(SettingsDropdownControlMixin, "Init", function(frame, initializer)
local data = getInitializerData(initializer)
- if not data or data._lsbKind ~= "scrollDropdown" then
+ if not data or (data._lsbKind ~= "dropdown" and data._lsbKind ~= "scrollDropdown") then
if frame._lsbOriginalSetValue then
frame.SetValue = frame._lsbOriginalSetValue
end
@@ -109,7 +119,7 @@ if not lib._scrollDropdownHookInstalled and hooksecurefunc and SettingsDropdownC
return
end
- configureScrollDropdownFrame(frame, initializer)
+ configureDropdownFrame(frame, initializer, data)
end)
lib._scrollDropdownHookInstalled = true
diff --git a/Libs/LibSettingsBuilder/Tests/Controls_spec.lua b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua
index 3b7349c5..e540c451 100644
--- a/Libs/LibSettingsBuilder/Tests/Controls_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Controls_spec.lua
@@ -78,4 +78,150 @@ describe("LibSettingsBuilder Controls", function()
assert.is_function(hooks[_G.SettingsDropdownControlMixin].Init)
assert.is_function(hooks[_G.SettingsSliderControlMixin].Init)
end)
+
+ it("refreshes standard dropdown text through the label map after Blizzard init", function()
+ local dropdownHook
+
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ _G.hooksecurefunc = function(target, method, fn)
+ if target == _G.SettingsDropdownControlMixin and method == "Init" then
+ dropdownHook = fn
+ end
+ end
+ _G.SettingsListElementMixin = {}
+ _G.SettingsDropdownControlMixin = {}
+ _G.SettingsSliderControlMixin = {}
+ _G.CreateFrame = function()
+ return createScriptableFrame()
+ end
+
+ TestHelpers.LoadLibSettingsBuilder()
+
+ local profile = { general = { mode = "chain" } }
+ local defaults = { general = { mode = "chain" } }
+ local originalCreateDropdown = Settings.CreateDropdown
+ local initializer
+ rawset(Settings, "CreateDropdown", function(...)
+ initializer = originalCreateDropdown(...)
+ return initializer
+ end)
+
+ local builder = LibStub("LibSettingsBuilder-1.0").New({
+ name = "Dropdown Labels",
+ store = function()
+ return profile
+ end,
+ defaults = function()
+ return defaults
+ end,
+ onChanged = function() end,
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ {
+ type = "dropdown",
+ path = "mode",
+ name = "Mode",
+ values = {
+ chain = "Attached",
+ detached = "Detached",
+ free = "Free",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ assert.is_table(builder:GetPage("general", "main"))
+ initializer.GetSetting = function()
+ return {
+ GetValue = function()
+ return "OUTLINE"
+ end,
+ }
+ end
+ local displayedText, originalValue
+ local frame = {
+ Control = {
+ Dropdown = {
+ OverrideText = function(_, text)
+ displayedText = text
+ end,
+ },
+ },
+ SetValue = function(_, value)
+ originalValue = value
+ end,
+ }
+
+ dropdownHook(frame, initializer)
+
+ assert.are.equal("Attached", displayedText)
+
+ frame:SetValue("free")
+
+ assert.are.equal("free", originalValue)
+ assert.are.equal("Free", displayedText)
+ end)
+
+ it("passes custom row settings through initializer data", function()
+ TestHelpers.SetupLibStub()
+ TestHelpers.SetupSettingsStubs()
+ _G.hooksecurefunc = function() end
+ _G.SettingsListElementMixin = {}
+ _G.SettingsDropdownControlMixin = {}
+ _G.SettingsSliderControlMixin = {}
+ _G.CreateFrame = function()
+ return createScriptableFrame()
+ end
+
+ TestHelpers.LoadLibSettingsBuilder()
+
+ local profile = { general = { font = "Expressway" } }
+ local defaults = { general = { font = "Expressway" } }
+ local builder = LibStub("LibSettingsBuilder-1.0").New({
+ name = "Custom Setting Data",
+ store = function()
+ return profile
+ end,
+ defaults = function()
+ return defaults
+ end,
+ onChanged = function() end,
+ sections = {
+ {
+ key = "general",
+ name = "General",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ {
+ type = "custom",
+ path = "font",
+ name = "Font",
+ template = "TestFontPickerTemplate",
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ local initializer = builder:GetPage("general", "main")._category:GetLayout()._initializers[1]
+ local data = initializer:GetData()
+
+ assert.is_table(data.setting)
+ assert.are.equal("Expressway", data.setting:GetValue())
+ end)
end)
diff --git a/Locales/en.lua b/Locales/en.lua
index ba4ca710..e326bd19 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -223,14 +223,11 @@ L["DEFAULT_COLOR"] = "Default color"
--------------------------------------------------------------------------------
L["TICK_MARKS_DESC"] = "Customize tick marks for the power bar. Marks are saved per class and specialization."
-L["TICK_MARKS_CLEAR_CONFIRM"] = "Are you sure you want to remove all tick marks for this spec?"
L["DEFAULT_WIDTH"] = "Default width"
L["ADD_TICK_MARK"] = "Add Tick Mark"
L["ADD"] = "Add"
L["REMOVE"] = "Remove"
L["TICK_N"] = "Tick %d"
-L["NO_TICK_MARKS"] = "%s - no tick marks configured."
-L["TICK_COUNT"] = "%s - %d tick mark(s) configured."
--------------------------------------------------------------------------------
-- Extra Icons Options
diff --git a/Modules/ExternalBars.lua b/Modules/ExternalBars.lua
index 8e7d7530..d88092cb 100644
--- a/Modules/ExternalBars.lua
+++ b/Modules/ExternalBars.lua
@@ -14,7 +14,6 @@ ns.Addon.ExternalBars = ExternalBars
local PLAYER_UNIT = "player"
local SPELL_COLOR_SCOPE = C.SCOPE_EXTERNALBARS
local canAccessTable = _G.canaccesstable
-local secondsToTimeAbbrev = _G.SecondsToTimeAbbrev
local function getSpellColors()
return ns.SpellColors.Get(SPELL_COLOR_SCOPE)
@@ -26,12 +25,12 @@ end
---@field name string|nil Non-secret aura name used for color lookup and optional display.
---@field spellID number|nil Non-secret spell ID used for spell-color lookup.
---@field texture string|number|nil Aura icon texture forwarded to widget APIs.
----@field duration number|nil Aura duration forwarded to the cooldown widget.
----@field expirationTime number|nil Aura expiration time used only when non-secret duration text is allowed.
----@field timeMod number|nil Aura time modifier forwarded to the cooldown widget.
+---@field duration number|nil Aura duration retained only for diagnostics.
+---@field expirationTime number|nil Aura expiration time retained only for diagnostics.
+---@field durationObject table|nil Aura duration object consumed by StatusBar timer APIs.
---@field durationIsSecret boolean Whether the packed aura duration is secret.
----@field canShowDurationText boolean Whether duration text can be refreshed safely in Lua.
----@field hasRenderableDuration boolean Whether the cooldown widget can be configured for this aura.
+---@field canShowDurationText boolean Whether duration text can be refreshed through engine formatting.
+---@field canUpdateDurationBar boolean Whether duration progress can be refreshed through the StatusBar timer.
---@class ECM_ExternalBarStatusBar : StatusBar Shared-status bar surface for one external aura row.
---@field Name FontString Spell name text.
@@ -41,7 +40,6 @@ end
---@class ECM_ExternalBarMixin : Frame Reusable bar row styled by the shared BuffBars helpers.
---@field __ecmHooked boolean Whether the shared child-bar styling is allowed to target this frame.
---@field Bar ECM_ExternalBarStatusBar Inner status bar surface.
----@field Cooldown Cooldown Cooldown overlay rendering the draining fill.
---@field Icon Frame Icon frame containing the texture regions expected by `FrameUtil`.
---@field cooldownInfo { spellID: number|nil } Spell metadata consumed by `SpellColors`.
---@field _ecmAuraIndex number Aura array position currently bound to this pooled bar.
@@ -116,11 +114,6 @@ local function formatDurationText(remaining)
return ""
end
- if type(secondsToTimeAbbrev) == "function" then
- local text = secondsToTimeAbbrev(remaining)
- return text or ""
- end
-
if remaining >= 60 then
local minutes = math.floor(remaining / 60)
local seconds = math.floor(remaining % 60)
@@ -134,6 +127,27 @@ local function formatDurationText(remaining)
return string.format("%.1f", remaining)
end
+---@param auraState ECM_ExternalAuraState|nil
+---@return number|nil
+local function getRemainingDuration(auraState)
+ if auraState == nil or auraState.durationObject == nil then
+ return nil
+ end
+
+ return auraState.durationObject:GetRemainingDuration()
+end
+
+---@param durationText FontString
+---@param remaining number
+local function setDurationText(durationText, remaining)
+ if issecretvalue(remaining) then
+ durationText:SetFormattedText("%.0f", remaining)
+ return
+ end
+
+ durationText:SetText(formatDurationText(remaining))
+end
+
---@param bars ECM_ExternalBarMixin[]
---@param container Frame
---@param growsUp boolean
@@ -248,11 +262,11 @@ function ExternalBars:_GetBarDiagnostics(index, bar, auraState)
local iconTexture = bar and bar._iconTexture or nil
local durationIsSecret = nil
local canShowDurationText = nil
- local hasRenderableDuration = nil
+ local canUpdateDurationBar = nil
if auraState then
durationIsSecret = auraState.durationIsSecret
canShowDurationText = auraState.canShowDurationText
- hasRenderableDuration = auraState.hasRenderableDuration
+ canUpdateDurationBar = auraState.canUpdateDurationBar
end
return {
@@ -264,7 +278,7 @@ function ExternalBars:_GetBarDiagnostics(index, bar, auraState)
texture = auraState and auraState.texture or nil,
durationIsSecret = durationIsSecret,
canShowDurationText = canShowDurationText,
- hasRenderableDuration = hasRenderableDuration,
+ canUpdateDurationBar = canUpdateDurationBar,
barExists = bar ~= nil,
barShown = getFrameShown(bar),
barWidth = getFrameWidth(bar),
@@ -317,35 +331,64 @@ end
---@param bar ECM_ExternalBarMixin
---@param auraState ECM_ExternalAuraState|nil
---@param showDuration boolean|nil
-function ExternalBars:_RefreshBarDurationText(bar, auraState, showDuration)
+---@param remaining number|nil
+function ExternalBars:_RefreshBarDurationText(bar, auraState, showDuration, remaining)
local durationText = bar and bar.Bar and bar.Bar.Duration
if not durationText then
return
end
- if showDuration == false or not auraState or not auraState.canShowDurationText then
+ if showDuration == false or auraState == nil or not auraState.canShowDurationText then
durationText:SetText(nil)
durationText:Hide()
return
end
- local remaining = auraState.expirationTime - GetTime()
- if remaining < 0 then
- remaining = 0
+ remaining = remaining or getRemainingDuration(auraState)
+ if remaining == nil then
+ durationText:SetText(nil)
+ durationText:Hide()
+ return
end
- durationText:SetText(formatDurationText(remaining))
+ setDurationText(durationText, remaining)
durationText:Show()
end
-function ExternalBars:_RefreshDurationTexts()
+---@param bar ECM_ExternalBarMixin
+---@param auraState ECM_ExternalAuraState|nil
+---@return number|nil
+function ExternalBars:_RefreshBarDurationProgress(bar, auraState)
+ local statusBar = bar and bar.Bar
+ if not statusBar then
+ return nil
+ end
+
+ if auraState == nil or auraState.durationObject == nil then
+ statusBar:SetMinMaxValues(0, 1)
+ statusBar:SetValue(1)
+ return nil
+ end
+
+ statusBar:SetMinMaxValues(0, 1)
+ statusBar:SetTimerDuration(
+ auraState.durationObject,
+ Enum.StatusBarInterpolation.ExponentialEaseOut,
+ Enum.StatusBarTimerDirection.RemainingTime
+ )
+ statusBar:SetToTargetValue()
+ return getRemainingDuration(auraState)
+end
+
+function ExternalBars:_RefreshDurationDisplays()
local moduleConfig = self:GetModuleConfig()
- if not moduleConfig or moduleConfig.showDuration == false then
+ if not moduleConfig then
self:_StopDurationTicker()
return
end
local hasEligibleBars = false
+ local showDuration = moduleConfig.showDuration ~= false
local barPool = self._barPool or {}
local auraStates = self._auraStates or {}
local activeAuraCount = self._activeAuraCount or 0
@@ -353,11 +396,11 @@ function ExternalBars:_RefreshDurationTexts()
for index = 1, activeAuraCount do
local bar = barPool[index]
local auraState = auraStates[index]
- if bar and bar:IsShown() and auraState and auraState.canShowDurationText then
+ if bar and bar:IsShown() and auraState and auraState.canUpdateDurationBar then
hasEligibleBars = true
end
if bar then
- self:_RefreshBarDurationText(bar, auraState, true)
+ self:_RefreshBarDurationText(bar, auraState, showDuration)
end
end
@@ -378,9 +421,9 @@ function ExternalBars:_RestartDurationTicker()
local activeAuraCount = self._activeAuraCount or 0
for index = 1, activeAuraCount do
local auraState = auraStates[index]
- if auraState and auraState.canShowDurationText then
+ if auraState and auraState.canUpdateDurationBar then
self._durationTicker = C_Timer.NewTicker(0.1, function()
- self:_RefreshDurationTexts()
+ self:_RefreshDurationDisplays()
end)
return
end
@@ -448,16 +491,6 @@ function ExternalBars:_ensureBar(index)
bar.Bar.Duration:SetJustifyV("MIDDLE")
bar.Bar.Duration:SetWordWrap(false)
- bar.Cooldown = CreateFrame("Cooldown", nil, bar.Bar, "CooldownFrameTemplate")
- bar.Cooldown:SetAllPoints(bar.Bar)
- bar.Cooldown:SetFrameLevel(bar.Bar:GetFrameLevel() + 2)
- bar.Cooldown:SetDrawSwipe(true)
- bar.Cooldown:SetDrawEdge(false)
- bar.Cooldown:SetDrawBling(false)
- bar.Cooldown:SetReverse(true)
- bar.Cooldown:SetHideCountdownNumbers(true)
- bar.Cooldown:SetSwipeColor(0, 0, 0, 1)
-
bar:Hide()
self._barPool[index] = bar
return bar
@@ -484,7 +517,8 @@ function ExternalBars:_hideExcessBars(activeCount)
bar.Bar.Name:SetText(nil)
bar.Bar.Duration:SetText(nil)
bar.Bar.Duration:Hide()
- bar.Cooldown:Clear()
+ bar.Bar:SetMinMaxValues(0, 1)
+ bar.Bar:SetValue(1)
bar:Hide()
end
end
@@ -505,13 +539,12 @@ function ExternalBars:_ConfigureBar(bar, auraState, moduleConfig, globalConfig,
StyleChildBar(self, bar, styleConfig, globalConfig, spellColors)
spellColors:DiscoverBar(bar)
- self:_RefreshBarDurationText(bar, auraState, moduleConfig and moduleConfig.showDuration ~= false)
-
- if auraState.hasRenderableDuration then
- bar.Cooldown:SetCooldownDuration(auraState.duration, auraState.timeMod)
- else
- bar.Cooldown:Clear()
- end
+ self:_RefreshBarDurationText(
+ bar,
+ auraState,
+ moduleConfig and moduleConfig.showDuration ~= false,
+ self:_RefreshBarDurationProgress(bar, auraState)
+ )
bar:Show()
end
@@ -656,6 +689,8 @@ function ExternalBars:OnExternalAurasUpdated()
local expirationTime = info.expirationTime
local durationIsSecret = issecretvalue(duration)
local expirationTimeIsSecret = issecretvalue(expirationTime)
+ local durationObject = C_UnitAuras.GetAuraDuration(PLAYER_UNIT, auraInstanceID)
+ local canUpdateDurationBar = durationObject ~= nil
auraState.index = index
auraState.auraInstanceID = auraInstanceID
@@ -664,14 +699,10 @@ function ExternalBars:OnExternalAurasUpdated()
auraState.texture = info.texture
auraState.duration = duration
auraState.expirationTime = expirationTime
- auraState.timeMod = info.timeMod
+ auraState.durationObject = durationObject
auraState.durationIsSecret = durationIsSecret
- auraState.canShowDurationText = not durationIsSecret
- and not expirationTimeIsSecret
- and type(duration) == "number"
- and duration > 0
- and type(expirationTime) == "number"
- auraState.hasRenderableDuration = durationIsSecret or (type(duration) == "number" and duration > 0)
+ auraState.canShowDurationText = canUpdateDurationBar
+ auraState.canUpdateDurationBar = canUpdateDurationBar
if auraDiagnostics then
auraDiagnostics[#auraDiagnostics + 1] = {
@@ -684,7 +715,7 @@ function ExternalBars:OnExternalAurasUpdated()
durationIsSecret = durationIsSecret,
expirationTimeIsSecret = expirationTimeIsSecret,
canShowDurationText = auraState.canShowDurationText,
- hasRenderableDuration = auraState.hasRenderableDuration,
+ canUpdateDurationBar = auraState.canUpdateDurationBar,
}
end
end
diff --git a/Modules/ExtraIcons.lua b/Modules/ExtraIcons.lua
index cc1e9ce8..28e7dcd2 100644
--- a/Modules/ExtraIcons.lua
+++ b/Modules/ExtraIcons.lua
@@ -218,7 +218,7 @@ local function createIcon(parent, size, borderScale)
icon.Border:SetPoint("CENTER")
icon.Border:SetSize(size * borderScale[1], size * borderScale[2])
- icon.Count = icon:CreateFontString(nil, "OVERLAY", "NumberFontNormalSmall")
+ icon.Count = icon:CreateFontString(nil, "OVERLAY")
icon.Count:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", -2, 2)
icon.Count:SetJustifyH("RIGHT")
icon.Count:SetJustifyV("BOTTOM")
@@ -279,9 +279,9 @@ local function setIconCountText(icon, text)
end
end
-local function updateIconCountText(icon, config)
+local function updateIconCountText(icon, globalConfig, config)
if not icon.Count then return end
- FrameUtil.ApplyFont(icon.Count, nil, config)
+ FrameUtil.ApplyFont(icon.Count, globalConfig, config)
if icon.itemId and (not config or config.showStackCount ~= false) then
local count = C_Item.GetItemCount(icon.itemId)
@@ -417,6 +417,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
end
local fontPath, fontSize, fontFlags = getSiblingFont(blizzFrame)
+ local globalConfig = self:GetGlobalConfig()
local iconSize = DEFAULT_SIZE
local viewerScale = blizzFrame.iconScale or 1.0
local spacing = blizzFrame.childXPadding or 0
@@ -467,7 +468,7 @@ function ExtraIcons:_updateSingleViewer(viewerKey, entries, isEditing, sharedOff
icon:Show()
updateIconCooldown(icon)
- updateIconCountText(icon, moduleConfig)
+ updateIconCountText(icon, globalConfig, moduleConfig)
if fontPath and fontSize then
local region = icon.Cooldown:GetRegions()
@@ -533,12 +534,13 @@ function ExtraIcons:Refresh(why, force)
local refreshed = false
local moduleConfig = self.GetModuleConfig and self:GetModuleConfig() or nil
+ local globalConfig = self:GetGlobalConfig()
for _, vs in pairs(self._viewers) do
if vs.container and vs.container:IsShown() then
for _, icon in ipairs(vs.iconPool) do
if icon:IsShown() and (icon.slotId or icon.itemId or icon.spellId) then
updateIconCooldown(icon)
- updateIconCountText(icon, moduleConfig)
+ updateIconCountText(icon, globalConfig, moduleConfig)
end
end
refreshed = true
diff --git a/Tests/Modules/ExternalBars_spec.lua b/Tests/Modules/ExternalBars_spec.lua
index da2efd36..dad78439 100644
--- a/Tests/Modules/ExternalBars_spec.lua
+++ b/Tests/Modules/ExternalBars_spec.lua
@@ -21,6 +21,7 @@ describe("ExternalBars real source", function()
local registerFrameCalls
local unregisterFrameCalls
local auraDataByInstanceID
+ local auraDurationByInstanceID
local colorLookupScopes
local discoveredScopes
local spellColorStores
@@ -40,6 +41,12 @@ describe("ExternalBars real source", function()
self.__text = text
end
+ function fontString:SetFormattedText(formatText, ...)
+ self.__formatText = formatText
+ self.__formatArgs = { ... }
+ self.__text = string.format(formatText, ...)
+ end
+
function fontString:GetText()
return self.__text
end
@@ -190,54 +197,17 @@ describe("ExternalBars real source", function()
return self.__value
end
- return bar
- end
-
- local function makeCooldownFrame(parent)
- local cooldown = addFrameFeatures(makeFrame({ shown = true }), parent)
- cooldown.__setCooldownDurationCalls = {}
- cooldown.__clearCalls = 0
-
- function cooldown:SetDrawSwipe(value)
- self.__drawSwipe = value
- end
-
- function cooldown:SetDrawEdge(value)
- self.__drawEdge = value
- end
-
- function cooldown:SetDrawBling(value)
- self.__drawBling = value
- end
-
- function cooldown:SetReverse(value)
- self.__reverse = value
- end
-
- function cooldown:SetHideCountdownNumbers(value)
- self.__hideCountdownNumbers = value
- end
-
- function cooldown:SetSwipeColor(r, g, b, a)
- self.__swipeColor = { r, g, b, a }
- end
-
- function cooldown:SetCooldownDuration(duration, timeMod)
- self.__lastDuration = duration
- self.__lastTimeMod = timeMod
- self.__setCooldownDurationCalls[#self.__setCooldownDurationCalls + 1] = {
- duration = duration,
- timeMod = timeMod,
- }
+ function bar:SetTimerDuration(durationObject, interpolation, direction)
+ self.__timerDuration = durationObject
+ self.__timerInterpolation = interpolation
+ self.__timerDirection = direction
end
- function cooldown:Clear()
- self.__clearCalls = self.__clearCalls + 1
- self.__lastDuration = nil
- self.__lastTimeMod = nil
+ function bar:SetToTargetValue()
+ self.__setToTargetValueCalls = (self.__setToTargetValueCalls or 0) + 1
end
- return cooldown
+ return bar
end
local function createFrameStub(frameType, _, parent)
@@ -245,17 +215,32 @@ describe("ExternalBars real source", function()
return makeStatusBarFrame(parent)
end
- if frameType == "Cooldown" then
- return makeCooldownFrame(parent)
+ return addFrameFeatures(makeFrame({ shown = true }), parent)
+ end
+
+ local function makeDurationObject(aura)
+ if aura.durationObject == false then
+ return nil
+ end
+ if aura.durationObject ~= nil then
+ return aura.durationObject
end
- return addFrameFeatures(makeFrame({ shown = true }), parent)
+ return {
+ GetRemainingDuration = function()
+ if aura.remaining ~= nil then
+ return aura.remaining
+ end
+ return aura.expirationTime - fakeTime
+ end,
+ }
end
local function setViewerAuras(auraDefs)
viewer.auraInfo = {}
viewer.auraFrames = {}
auraDataByInstanceID = {}
+ auraDurationByInstanceID = {}
for index, aura in ipairs(auraDefs or {}) do
viewer.auraInfo[index] = {
@@ -270,6 +255,7 @@ describe("ExternalBars real source", function()
icon = aura.texture,
}
auraDataByInstanceID[aura.auraInstanceID] = aura.auraData
+ auraDurationByInstanceID[aura.auraInstanceID] = makeDurationObject(aura)
end
end
@@ -310,6 +296,7 @@ describe("ExternalBars real source", function()
"CreateFrame",
"LibStub",
"SecondsToTimeAbbrev",
+ "Enum",
"wipe",
})
end)
@@ -327,6 +314,7 @@ describe("ExternalBars real source", function()
registerFrameCalls = 0
unregisterFrameCalls = 0
auraDataByInstanceID = {}
+ auraDurationByInstanceID = {}
colorLookupScopes = {}
discoveredScopes = {}
spellColorStores = {}
@@ -511,13 +499,28 @@ describe("ExternalBars real source", function()
GetAuraDataByAuraInstanceID = function(_, auraInstanceID)
return auraDataByInstanceID[auraInstanceID]
end,
+ GetAuraDuration = function(_, auraInstanceID)
+ return auraDurationByInstanceID[auraInstanceID]
+ end,
+ }
+ _G.Enum = {
+ StatusBarInterpolation = {
+ ExponentialEaseOut = "ExponentialEaseOut",
+ None = "None",
+ },
+ StatusBarTimerDirection = {
+ RemainingTime = "RemainingTime",
+ ElapsedTime = "ElapsedTime",
+ },
}
_G.wipe = function(tbl)
for key in pairs(tbl) do
tbl[key] = nil
end
end
- _G.SecondsToTimeAbbrev = nil
+ _G.SecondsToTimeAbbrev = function()
+ return "%d"
+ end
_G.C_Timer = {
After = function(_, callback)
afterCallbacks[#afterCallbacks + 1] = callback
@@ -578,7 +581,7 @@ describe("ExternalBars real source", function()
ExternalBars:OnInitialize()
end)
- it("creates bars from external aura updates and configures cooldown duration", function()
+ it("creates bars from external aura updates and refreshes duration progress", function()
setViewerAuras({
{
auraInstanceID = 11,
@@ -598,12 +601,47 @@ describe("ExternalBars real source", function()
assert.are.equal("Ironbark", bar.Bar.Name:GetText())
assert.are.equal("12", bar.Bar.Duration:GetText())
assert.is_true(bar.Bar.Duration:IsShown())
- assert.same({ duration = 12, timeMod = 1.5 }, bar.Cooldown.__setCooldownDurationCalls[1])
+ assert.are.equal(0, bar.Bar.__minValue)
+ assert.are.equal(1, bar.Bar.__maxValue)
+ assert.are.equal(auraDurationByInstanceID[11], bar.Bar.__timerDuration)
+ assert.are.equal("ExponentialEaseOut", bar.Bar.__timerInterpolation)
+ assert.are.equal("RemainingTime", bar.Bar.__timerDirection)
+ assert.are.equal(1, bar.Bar.__setToTargetValueCalls)
+ assert.is_nil(bar.Cooldown)
assert.same({ "ExternalBars:UpdateAuras" }, requestLayoutReasons)
assert.same({ ns.Constants.SCOPE_EXTERNALBARS }, colorLookupScopes)
assert.same({ ns.Constants.SCOPE_EXTERNALBARS }, discoveredScopes)
assert.same({ 0.40, 0.78, 0.95, 1.0 }, { bar.Bar:GetStatusBarColor() })
assert.are.equal(1, #durationTickers)
+
+ fakeTime = 103
+ durationTickers[1].callback()
+
+ assert.are.equal("9.0", bar.Bar.Duration:GetText())
+ end)
+
+ it("configures duration bar progress when duration text is hidden", function()
+ profile.externalBars.showDuration = false
+
+ setViewerAuras({
+ {
+ auraInstanceID = 11,
+ texture = 5011,
+ duration = 12,
+ expirationTime = 112,
+ timeMod = 1,
+ auraData = { name = "Ironbark", spellId = 102342 },
+ },
+ })
+
+ assert.is_true(syncAndLayout("test-hidden-text"))
+
+ local bar = assert(ExternalBars._barPool[1])
+ assert.is_false(bar.Bar.Duration:IsShown())
+ assert.is_nil(bar.Bar.Duration:GetText())
+ assert.are.equal(auraDurationByInstanceID[11], bar.Bar.__timerDuration)
+ assert.are.equal("RemainingTime", bar.Bar.__timerDirection)
+ assert.are.equal(0, #durationTickers)
end)
it("emits detailed aura diagnostics when debug logging is enabled", function()
@@ -644,7 +682,7 @@ describe("ExternalBars real source", function()
durationIsSecret = false,
expirationTimeIsSecret = false,
canShowDurationText = true,
- hasRenderableDuration = true,
+ canUpdateDurationBar = true,
}, logEntry.payload.auras[1])
end)
@@ -691,7 +729,7 @@ describe("ExternalBars real source", function()
texture = 5011,
durationIsSecret = false,
canShowDurationText = true,
- hasRenderableDuration = true,
+ canUpdateDurationBar = true,
barExists = true,
barShown = true,
barWidth = 0,
@@ -702,7 +740,7 @@ describe("ExternalBars real source", function()
}, logEntry.payload.bars[1])
end)
- it("hides duration text but still configures cooldown and schedules the all-secret color retry path", function()
+ it("formats secret duration text through engine formatting and schedules the color retry path", function()
_G.issecretvalue = function()
return true
end
@@ -714,6 +752,7 @@ describe("ExternalBars real source", function()
duration = "secret-duration",
expirationTime = "secret-expiration",
timeMod = "secret-mod",
+ remaining = 8,
auraData = { name = "secret-name", spellId = 987654 },
},
})
@@ -722,12 +761,16 @@ describe("ExternalBars real source", function()
local bar = assert(ExternalBars._barPool[1])
assert.is_true(bar:IsShown())
- assert.is_false(bar.Bar.Duration:IsShown())
- assert.is_nil(bar.Bar.Duration:GetText())
- assert.same({ duration = "secret-duration", timeMod = "secret-mod" }, bar.Cooldown.__setCooldownDurationCalls[1])
+ assert.is_true(bar.Bar.Duration:IsShown())
+ assert.are.equal("8", bar.Bar.Duration:GetText())
+ assert.are.equal("%.0f", bar.Bar.Duration.__formatText)
+ assert.are.equal(0, bar.Bar.__minValue)
+ assert.are.equal(1, bar.Bar.__maxValue)
+ assert.are.equal(auraDurationByInstanceID[22], bar.Bar.__timerDuration)
+ assert.is_nil(bar.Cooldown)
assert.are.equal(1, #retryTimers)
assert.same({ true, "secrets" }, { ExternalBars:IsEditLocked() })
- assert.are.equal(0, #durationTickers)
+ assert.are.equal(1, #durationTickers)
end)
it("reuses pooled bars and hides excess bars when aura count shrinks", function()
diff --git a/Tests/Modules/ExtraIcons_spec.lua b/Tests/Modules/ExtraIcons_spec.lua
index 388ba0c1..d545288b 100644
--- a/Tests/Modules/ExtraIcons_spec.lua
+++ b/Tests/Modules/ExtraIcons_spec.lua
@@ -197,6 +197,7 @@ describe("ExtraIcons real source", function()
local spellCooldownInfos
local spellCharges
local applyFontCalls
+ local globalConfig
local ratedMap
setup(function()
@@ -239,6 +240,7 @@ describe("ExtraIcons real source", function()
spellCooldownInfos = {}
spellCharges = {}
applyFontCalls = {}
+ globalConfig = { font = "Global Font", fontSize = 11, fontOutline = "OUTLINE", fontShadow = false }
ratedMap = false
_G.C_SpellBook = {
IsSpellKnown = function(spellId)
@@ -270,14 +272,17 @@ describe("ExtraIcons real source", function()
RequestLayout = function() end,
},
FrameUtil = {
- ApplyFont = function(fontString, globalConfig, moduleConfig)
+ ApplyFont = function(fontString, appliedGlobalConfig, moduleConfig)
applyFontCalls[#applyFontCalls + 1] = {
fontString = fontString,
- globalConfig = globalConfig,
+ globalConfig = appliedGlobalConfig,
moduleConfig = moduleConfig,
}
end,
},
+ GetGlobalConfig = function()
+ return globalConfig
+ end,
}
TestHelpers.LoadChunk("Constants.lua", "Unable to load Constants.lua")(nil, ns)
TestHelpers.LoadChunk("Locales/en.lua", "Unable to load Locales/en.lua")(nil, ns)
@@ -383,8 +388,11 @@ describe("ExtraIcons real source", function()
end
return texture
end
- frame.CreateFontString = function()
+ frame.CreateFontString = function(_, name, drawLayer, template)
local fontString = TestHelpers.makeRegion("FontString")
+ fontString.__name = name
+ fontString.__drawLayer = drawLayer
+ fontString.__template = template
fontString.SetPoint = function() end
fontString.SetJustifyH = function() end
fontString.SetJustifyV = function() end
@@ -448,6 +456,7 @@ describe("ExtraIcons real source", function()
TestHelpers.LoadChunk("Modules/ExtraIcons.lua", "Unable to load Modules/ExtraIcons.lua")(nil, ns)
ExtraIcons = assert(ns.Addon.ExtraIcons, "ExtraIcons module did not initialize")
+ ExtraIcons.GetGlobalConfig = ns.GetGlobalConfig
function ExtraIcons:IsEnabled()
return true
end
@@ -1392,7 +1401,9 @@ describe("ExtraIcons real source", function()
assert.are.equal("5", count.__text)
assert.is_true(count:IsShown())
assert.are.equal(count, applyFontCalls[#applyFontCalls].fontString)
+ assert.are.equal(globalConfig, applyFontCalls[#applyFontCalls].globalConfig)
assert.are.equal(config, applyFontCalls[#applyFontCalls].moduleConfig)
+ assert.is_nil(count.__template)
config.showStackCount = false
assert.is_true(ExtraIcons:UpdateLayout("test"))
@@ -1428,6 +1439,7 @@ describe("ExtraIcons real source", function()
assert.is_true(#applyFontCalls > previousApplyCount)
assert.are.equal(count, applyFontCalls[#applyFontCalls].fontString)
+ assert.are.equal(globalConfig, applyFontCalls[#applyFontCalls].globalConfig)
assert.are.equal(config, applyFontCalls[#applyFontCalls].moduleConfig)
assert.is_true(applyFontCalls[#applyFontCalls].moduleConfig.overrideFont)
assert.are.equal("Expressway", applyFontCalls[#applyFontCalls].moduleConfig.font)
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index fc43fd6f..0e0426e1 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -78,11 +78,11 @@ describe("PowerBarTickMarksOptions", function()
assert.is_nil(ns.PowerBarTickMarksStore)
end)
- it("exports a page with page actions and list-based tick editors", function()
+ it("exports a page with list-based tick editors", function()
local captured = registerPageSpec()
assert.are.equal("Tick Marks", captured.name)
- assert.are.equal("pageActions", getRow(captured, "tickMarksPageActions").type)
+ assert.is_nil(getRow(captured, "tickMarksPageActions"))
assert.are.equal("list", getRow(captured, "tickCollection").type)
assert.are.equal("editor", getRow(captured, "tickCollection").variant)
assert.are.equal(320, getRow(captured, "tickCollection").height)
@@ -93,11 +93,9 @@ describe("PowerBarTickMarksOptions", function()
currentSpecIndex = nil
local captured = registerPageSpec()
- local defaultsAction = getRow(captured, "tickMarksPageActions").actions[1]
local tickCollection = getRow(captured, "tickCollection")
assert.are.same({}, tickCollection.items())
- assert.is_false(defaultsAction.enabled())
end)
it("add button appends a tick using the current defaults", function()
@@ -126,53 +124,6 @@ describe("PowerBarTickMarksOptions", function()
assert.are.same({ true }, refreshCalls)
end)
- it("defaults action clears only the current spec ticks after confirmation", function()
- local shownPopup
- local scheduledReason
-
- setTickMappings({
- [1] = {
- [2] = {
- { value = 50, width = 2, color = { r = 1, g = 1, b = 1, a = 1 } },
- },
- },
- [2] = {
- [1] = {
- { value = 30, width = 1, color = { r = 0, g = 1, b = 0, a = 1 } },
- },
- },
- })
-
- _G.StaticPopup_Show = function(name, _, _, data)
- shownPopup = name
- local dialog = _G.StaticPopupDialogs[name]
- dialog.OnAccept(nil, data)
- end
- ns.Runtime = {
- ScheduleLayoutUpdate = function(_, reason)
- scheduledReason = reason
- end,
- }
-
- local captured, refreshCalls = registerPageSpec()
- local defaultsAction = getRow(captured, "tickMarksPageActions").actions[1]
- local tickCollection = getRow(captured, "tickCollection")
-
- defaultsAction.onClick()
-
- assert.are.equal("ECM_CONFIRM_CLEAR_TICKS", shownPopup)
- assert.are.same({}, tickCollection.items())
-
- currentClassID = 2
- currentSpecIndex = 1
-
- local otherSpecItems = tickCollection.items()
- assert.are.equal(1, #otherSpecItems)
- assert.are.equal(30, otherSpecItems[1].fields[1].value)
- assert.are.equal("OptionsChanged", scheduledReason)
- assert.are.same({ true }, refreshCalls)
- end)
-
it("collection editor callbacks update color, values, widths, and removal without touching another spec", function()
local scheduledReasons = {}
local pickedColor = { r = 0.25, g = 0.5, b = 0.75, a = 1 }
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index 8b4ecd62..88fe7572 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -101,8 +101,6 @@ local function setDefaultWidth(width)
getTicksConfig().defaultWidth = width
end
-StaticPopupDialogs["ECM_CONFIRM_CLEAR_TICKS"] = ns.OptionUtil.MakeConfirmDialog(L["TICK_MARKS_CLEAR_CONFIRM"])
-
local registeredPage
function PowerBarTickMarksOptions.SetRegisteredPage(page)
registeredPage = page
@@ -128,24 +126,6 @@ local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
-local function clearAllTicks()
- setCurrentTicks({})
- scheduleUpdate()
- refreshPage()
-end
-
-local function getTickSummary()
- local _, _, localisedClassName, specName, className = ns.OptionUtil.GetCurrentClassSpec()
- local color = C.CLASS_COLORS[className] or C.COLOR_WHITE_HEX
- local classSpecLabel = "|cff" .. color .. (localisedClassName or "Unknown") .. "|r " .. (specName or "Unknown")
- local ticks = getCurrentTicks()
- local count = #ticks
- if count == 0 then
- return string.format(L["NO_TICK_MARKS"], classSpecLabel)
- end
- return string.format(L["TICK_COUNT"], classSpecLabel, count)
-end
-
local function buildTickCollectionItems()
local ticks = getCurrentTicks()
local items = {}
@@ -215,25 +195,6 @@ end
PowerBarTickMarksOptions.key = "tickMarks"
PowerBarTickMarksOptions.name = "Tick Marks"
PowerBarTickMarksOptions.rows = {
- {
- id = "tickMarksPageActions",
- type = "pageActions",
- name = PowerBarTickMarksOptions.name,
- actions = {
- {
- text = SETTINGS_DEFAULTS,
- width = 100,
- enabled = function()
- return #getCurrentTicks() > 0
- end,
- onClick = function()
- StaticPopup_Show("ECM_CONFIRM_CLEAR_TICKS", nil, nil, {
- onAccept = clearAllTicks,
- })
- end,
- },
- },
- },
{
id = "description",
type = "info",
@@ -243,15 +204,6 @@ PowerBarTickMarksOptions.rows = {
multiline = true,
height = 36,
},
- {
- id = "summary",
- type = "info",
- name = "",
- value = getTickSummary,
- wide = true,
- multiline = true,
- height = 28,
- },
{
id = "defaultColor",
type = "color",
diff --git a/docs/ExternalBars.md b/docs/ExternalBars.md
index d6aab565..33f8ed70 100644
--- a/docs/ExternalBars.md
+++ b/docs/ExternalBars.md
@@ -14,7 +14,7 @@ This module is intentionally absent from the `ARCHITECTURE.md` Event Reference t
| **Mixin** | `BarMixin.AddFrameMixin`; inherits [`FrameProto`](../BarMixin.lua) |
| **Events listened to** | None for aura data. `ExternalBars` does not call `RegisterEvent()` in `Modules/ExternalBars.lua`; aura refresh is driven by hooks on `ExternalDefensivesFrame:UpdateAuras()` plus the frame's `OnShow` / `OnHide`. `OnDisable()` still calls `UnregisterAllEvents()` as defensive cleanup. |
| **Hooks** | - Post-hook `ExternalDefensivesFrame:UpdateAuras()` → `OnExternalAurasUpdated()`
- `ExternalDefensivesFrame:HookScript("OnShow")` → refresh original-icon state, then resync aura state
- `ExternalDefensivesFrame:HookScript("OnHide")` → clear active rows, stop duration ticker, request layout
- `hideOriginalIcons` uses `ExternalDefensivesFrame:SetAlpha(0)` and `EnableMouse(false)` instead of `Hide()` so Blizzard keeps driving `UpdateAuras()` |
-| **Dependencies** | - `ns.SpellColors.Get("externalBars")` scoped color store
- `BarStyle.StyleChildBar(...)` shared BuffBars / ExternalBars row styling
- `Cooldown` overlays via `SetCooldownDuration(duration, timeMod)` for draining fill
- `C_UnitAuras.GetAuraDataByAuraInstanceID("player", auraInstanceID)` for accessible aura metadata
- `FrameUtil` lazy setters and icon helpers
- `ns.Runtime.RequestLayout(...)` / runtime layout passes |
+| **Dependencies** | - `ns.SpellColors.Get("externalBars")` scoped color store
- `BarStyle.StyleChildBar(...)` shared BuffBars / ExternalBars row styling
- `C_UnitAuras.GetAuraDataByAuraInstanceID("player", auraInstanceID)` for accessible aura metadata
- `C_UnitAuras.GetAuraDuration("player", auraInstanceID)` duration objects for secret-safe bar timers and text
- `FrameUtil` lazy setters and icon helpers
- `ns.Runtime.RequestLayout(...)` / runtime layout passes |
| **Options file(s)** | [`UI/ExternalBarsOptions.lua`](../UI/ExternalBarsOptions.lua), shared section registration in [`UI/SpellColorsPage.lua`](../UI/SpellColorsPage.lua) |
| **Options dependencies** | - `ns.OptionUtil` for disabled predicates, default-value transforms, module toggle handling, and layout breadcrumbs
- `LibSettingsBuilder` for the declarative Settings rows consumed by the root options tree
- `ns.SpellColors` for the scoped color store edited by the shared page
- `ns.SpellColorsPage` for `RegisterSection(...)` and the shared spell-colors editor |
@@ -123,7 +123,7 @@ flowchart TD
subgraph BLIZZARD["Blizzard frames mirrored"]
Viewer["ExternalDefensivesFrame\n(authoritative aura source)"]
AuraInfo["viewer.auraInfo[]"]
- AuraAPI["C_UnitAuras\nGetAuraDataByAuraInstanceID"]
+ AuraAPI["C_UnitAuras\nGetAuraDataByAuraInstanceID\nGetAuraDuration"]
end
subgraph ECM["ECM internals"]
@@ -133,7 +133,7 @@ flowchart TD
end
subgraph HELPERS["Shared helpers"]
- Cooldown["Cooldown overlays"]
+ DurationObjects["Aura DurationObjects"]
BarStyle["BarStyle.StyleChildBar"]
FrameUtil["FrameUtil"]
SpellColors["SpellColors store\nscope = \"externalBars\""]
@@ -151,7 +151,7 @@ flowchart TD
ExternalBars -->|StyleChildBar(...)| BarStyle
ExternalBars -->|LazySetWidth / Height / Anchors| FrameUtil
ExternalBars -->|Get + DiscoverBar + ClearDiscoveredKeys| SpellColors
- ExternalBars -->|SetCooldownDuration / Clear| Cooldown
+ ExternalBars -->|StatusBar:SetTimerDuration| DurationObjects
BarStyle --> FrameUtil
BarStyle --> SpellColors
@@ -222,15 +222,14 @@ classDiagram
+texture: string|number
+duration: number
+expirationTime: number
- +timeMod: number
+ +durationObject: DurationObject
+durationIsSecret: boolean
+canShowDurationText: boolean
- +hasRenderableDuration: boolean
+ +canUpdateDurationBar: boolean
}
class ECM_ExternalBarMixin {
+Bar: ECM_ExternalBarStatusBar
- +Cooldown: Cooldown
+Icon: Frame
+cooldownSpellID: number
+_ecmAuraIndex: number
@@ -243,11 +242,6 @@ classDiagram
+Pip: Texture
}
- class Cooldown {
- +SetCooldownDuration(duration, timeMod)
- +Clear()
- }
-
class BarStyle {
+StyleChildBar(module, frame, config, globalConfig, spellColors)
}
@@ -265,7 +259,6 @@ classDiagram
ExternalBars *-- ECM_ExternalAuraState : _auraStates[index]
ExternalBars *-- ECM_ExternalBarMixin : _barPool[index]
ECM_ExternalBarMixin *-- ECM_ExternalBarStatusBar
- ECM_ExternalBarMixin *-- Cooldown
ExternalBars ..> BarStyle : styles rows
ExternalBars ..> ECM_SpellColorStore : scope "externalBars"
```
@@ -275,4 +268,4 @@ classDiagram
- `_auraStates[]` is keyed by Blizzard's `viewer.auraInfo` array index, not by `auraInstanceID`. `auraInstanceID` is preserved only for Blizzard aura API lookups.
- `hideOriginalIcons` is deliberately implemented as alpha and mouse suppression, not `Hide()`, so Blizzard continues to execute `ExternalDefensivesFrame:UpdateAuras()`.
- `UpdateLayout("PLAYER_SPECIALIZATION_CHANGED")` and `UpdateLayout("ProfileChanged")` both clear discovered spell-color keys for the `externalBars` scope before restyling rows.
-- Duration fill and duration text are separate paths: the `Cooldown` widget can render secret durations, while Lua text refresh only runs when expiration data is safe to inspect directly.
+- Duration fill is engine-rendered through `StatusBar:SetTimerDuration(durationObject, ..., RemainingTime)`. Duration text uses `SetFormattedText()` when remaining time is secret, so secret values are passed to Blizzard formatting APIs instead of Lua string formatting.
From 1c2ae28bcee6ca8f82ddfd217dd69dd2799e5a00 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 10:06:57 +1000
Subject: [PATCH 40/53] Fix tick controls.
---
.serena/project.yml | 82 ++++-----
.../Controls/CollectionFrames.lua | 23 ++-
.../Controls/Collections.lua | 10 +-
Libs/LibSettingsBuilder/Core.lua | 2 +-
.../Primitives/BlizzardControls.lua | 3 +
.../Tests/Collections_spec.lua | 166 +++++++++++++++++-
Tests/UI/PowerBarTickMarksOptions_spec.lua | 12 +-
UI/ExtraIconsOptions.lua | 5 -
UI/PowerBarTickMarksOptions.lua | 13 +-
9 files changed, 248 insertions(+), 68 deletions(-)
diff --git a/.serena/project.yml b/.serena/project.yml
index 0ac22307..380b8efb 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -3,15 +3,18 @@ project_name: "EnhancedCooldownManager"
# list of languages for which language servers are started; choose from:
-# al bash clojure cpp csharp
-# csharp_omnisharp dart elixir elm erlang
-# fortran fsharp go groovy haskell
-# java julia kotlin lua markdown
-# matlab nix pascal perl php
-# php_phpactor powershell python python_jedi r
-# rego ruby ruby_solargraph rust scala
-# swift terraform toml typescript typescript_vts
-# vue yaml zig
+# al ansible bash clojure cpp
+# cpp_ccls crystal csharp csharp_omnisharp dart
+# elixir elm erlang fortran fsharp
+# go groovy haskell haxe hlsl
+# java json julia kotlin lean4
+# lua luau markdown matlab msl
+# nix ocaml pascal perl php
+# php_phpactor powershell python python_jedi python_ty
+# r rego ruby ruby_solargraph rust
+# scala solidity swift systemverilog terraform
+# toml typescript typescript_vts vue yaml
+# zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
@@ -57,52 +60,19 @@ ignored_paths: []
# Added on 2025-04-18
read_only: false
-# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
-# Below is the complete list of tools for convenience.
-# To make sure you have the latest list of tools, and to view their descriptions,
-# execute `uv run scripts/print_tool_overview.py`.
-#
-# * `activate_project`: Activates a project by name.
-# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
-# * `create_text_file`: Creates/overwrites a file in the project directory.
-# * `delete_lines`: Deletes a range of lines within a file.
-# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
-# * `execute_shell_command`: Executes a shell command.
-# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
-# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
-# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
-# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
-# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
-# * `initial_instructions`: Gets the initial instructions for the current project.
-# Should only be used in settings where the system prompt cannot be set,
-# e.g. in clients you have no control over, like Claude Desktop.
-# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
-# * `insert_at_line`: Inserts content at a given line in a file.
-# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
-# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
-# * `list_memories`: Lists memories in Serena's project-specific memory store.
-# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
-# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
-# * `read_file`: Reads a file within the project directory.
-# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
-# * `remove_project`: Removes a project from the Serena configuration.
-# * `replace_lines`: Replaces a range of lines within a file with new content.
-# * `replace_symbol_body`: Replaces the full definition of a symbol.
-# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
-# * `search_for_pattern`: Performs a search for a pattern in the project.
-# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
-# * `switch_modes`: Activates modes by providing a list of their names
-# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
-# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
-# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
-# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
+# list of tool names to exclude.
+# This extends the existing exclusions (e.g. from the global configuration)
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
+# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
+# This extends the existing inclusions (e.g. from the global configuration).
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
+# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
@@ -113,11 +83,14 @@ fixed_tools: []
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
-# list of mode names that are to be activated by default.
-# The full set of modes to be activated is base_modes + default_modes.
-# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
+# list of mode names that are to be activated by default, overriding the setting in the global configuration.
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
+# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
+# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
@@ -147,3 +120,8 @@ ignored_memory_patterns: []
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
+
+# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
+# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
+# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
+added_modes:
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index 2c4389b5..c4fa65dd 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -24,6 +24,21 @@ local showFrame = internal.showFrame
local DISABLED_ROW_ALPHA = 0.5
local DEFAULT_LABEL_COLOR = { 1, 1, 1, 1 }
+local function preventMouseClickPropagation(frame)
+ if not frame then
+ return
+ end
+ if frame.SetPropagateMouseClicks then
+ frame:SetPropagateMouseClicks(false)
+ end
+ if frame.GetChildren then
+ local children = { frame:GetChildren() }
+ for i = 1, #children do
+ preventMouseClickPropagation(children[i])
+ end
+ end
+end
+
local function getFontObjectTextColor(fontObject)
if type(fontObject) == "string" then
fontObject = _G[fontObject]
@@ -279,6 +294,10 @@ local function ensureEditorCollectionRow(row)
row._fieldWidgets = {}
row._swatch = internal.createColorSwatch(row)
row._removeButton = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
+ preventMouseClickPropagation(row._removeButton)
+ if row._removeButton.RegisterForClicks then
+ row._removeButton:RegisterForClicks("LeftButtonUp")
+ end
row._removeButton:SetSize(70, 22)
end
@@ -289,6 +308,7 @@ local function ensureEditorFieldWidgets(row, index)
end
local slider = CreateFrame("Slider", nil, row, "MinimalSliderWithSteppersTemplate")
+ preventMouseClickPropagation(slider)
local valueText = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
valueText:SetJustifyH("LEFT")
@@ -305,7 +325,7 @@ local function refreshEditorCollectionRow(row, item)
row._label:SetText(item.label or "")
applyCollectionRowStyle(row, item)
- bindCollectionRowTooltip(row, item)
+ bindCollectionRowTooltip(row, nil)
local previousValueText = nil
local fields = item.fields or {}
@@ -354,6 +374,7 @@ local function refreshEditorCollectionRow(row, item)
field.onValueChanged(rounded, item, row)
end
end)
+ preventMouseClickPropagation(slider)
previousValueText = valueText
end
diff --git a/Libs/LibSettingsBuilder/Controls/Collections.lua b/Libs/LibSettingsBuilder/Controls/Collections.lua
index b1fbe9bc..72b4fd05 100644
--- a/Libs/LibSettingsBuilder/Controls/Collections.lua
+++ b/Libs/LibSettingsBuilder/Controls/Collections.lua
@@ -28,12 +28,18 @@ function internal.createCollectionInitializer(self, spec, errorPrefix)
controlInitializer._lsbEnabled = enabled
local activeFrame = controlInitializer._lsbActiveFrame
if activeFrame then
- internal.applyCanvasState(self, activeFrame, enabled)
+ applyCollectionFrame(activeFrame, data, controlInitializer)
+ if activeFrame.SetAlpha then
+ activeFrame:SetAlpha(enabled and 1 or 0.5)
+ end
+ if enabled == false then
+ internal.setCanvasInteractive(self, activeFrame, false)
+ end
end
end
initializer._lsbRefreshFrame = function(frame)
- applyCollectionFrame(frame, data, initializer)
+ initializer._lsbActiveFrame = frame
initializer:SetEnabled(initializer._lsbEnabled ~= false)
end
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index f309f2b0..dce4aa92 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -51,7 +51,7 @@
---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition.
---@field sections LibSettingsBuilderSectionConfig[]|nil Gets the optional section definitions registered under the root category.
-local MAJOR, MINOR = "LibSettingsBuilder-1.0", 5
+local MAJOR, MINOR = "LibSettingsBuilder-1.0", 6
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then
return
diff --git a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
index 5e3e86e2..917c1158 100644
--- a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
+++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
@@ -201,6 +201,9 @@ local function attachInlineSliderEditor(slider, textLabel, editBoxWidth)
local valueButton = CreateFrame("Button", nil, slider)
valueButton:RegisterForClicks("LeftButtonDown")
+ if valueButton.SetPropagateMouseClicks then
+ valueButton:SetPropagateMouseClicks(false)
+ end
valueButton:SetAllPoints(textLabel)
slider._lsbValueButton = valueButton
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index 76edc7d6..efc4cf74 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -69,14 +69,27 @@ describe("LibSettingsBuilder Collections", function()
}
end
_G.ScrollUtil = {
- InitScrollBoxListWithScrollBar = function() end,
+ InitScrollBoxListWithScrollBar = function(scrollBox, _, view)
+ scrollBox._scrollView = view
+ end,
}
TestHelpers.LoadLibSettingsBuilder()
end)
local function makeCollectionControl(clickedButtons)
local control = TestHelpers.makeFrame()
+ control._children = {}
local textColor = { 1, 1, 1, 1 }
+ control.SetShown = function(self, shown)
+ if shown then
+ self:Show()
+ else
+ self:Hide()
+ end
+ end
+ control.GetChildren = function(self)
+ return (table.unpack or unpack)(self._children)
+ end
control.SetText = function(self, text)
self._text = text
end
@@ -122,6 +135,40 @@ describe("LibSettingsBuilder Collections", function()
clickedButtons[#clickedButtons + 1] = self
end
end
+ control.SetPropagateMouseClicks = function(self, propagate)
+ self._propagateMouseClicks = propagate
+ end
+ control.SetMinMaxValues = function(self, minValue, maxValue)
+ self._minValue = minValue
+ self._maxValue = maxValue
+ end
+ control.SetValueStep = function(self, step)
+ self._valueStep = step
+ end
+ control.SetObeyStepOnDrag = function(self, obey)
+ self._obeyStepOnDrag = obey
+ end
+ control.SetValue = function(self, value)
+ self._value = value
+ end
+ control.SetColorRGB = function(self, r, g, b)
+ self._color = { r, g, b }
+ end
+ control.SetDataProvider = function(self, dataProvider)
+ self._dataProvider = dataProvider
+ if self._scrollView and self._scrollView._initializer then
+ self._rows = self._rows or {}
+ for index, item in ipairs(dataProvider.items or {}) do
+ local row = self._rows[index] or makeCollectionControl(clickedButtons)
+ self._rows[index] = row
+ if not row._testParented then
+ self._children[#self._children + 1] = row
+ row._testParented = true
+ end
+ self._scrollView._initializer(row, item)
+ end
+ end
+ end
control.CreateFontString = function()
return makeCollectionControl(clickedButtons)
end
@@ -320,6 +367,123 @@ describe("LibSettingsBuilder Collections", function()
assert.is_function(row._textureButtons.delete:GetScript("OnEnter"))
end)
+ it("prevents editor row controls from selecting the host settings row", function()
+ _G.CreateFrame = function()
+ return makeCollectionControl()
+ end
+
+ local item = {
+ label = "Tick 1",
+ fields = {
+ {
+ value = 50,
+ min = 1,
+ max = 100,
+ step = 1,
+ },
+ },
+ color = {
+ value = { r = 1, g = 1, b = 1, a = 1 },
+ },
+ remove = {
+ text = "Remove",
+ },
+ }
+ local host = makeCollectionControl()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+
+ lsb._internal.applyCollectionFrame(host, {
+ preset = "editor",
+ rowHeight = 34,
+ items = function()
+ return { item }
+ end,
+ })
+
+ local row = makeCollectionControl()
+ host._lsbCollectionView._initializer(row, assert(host._lsbCollectionDataProvider.items[1]))
+
+ local slider = row._fieldWidgets[1].slider
+ assert.is_false(row._mouseEnabled)
+ assert.is_nil(row:GetScript("OnEnter"))
+ assert.is_nil(row:GetScript("OnLeave"))
+ assert.is_false(slider._propagateMouseClicks)
+ assert.is_false(slider._lsbValueButton._propagateMouseClicks)
+ assert.is_false(row._swatch._propagateMouseClicks)
+ assert.is_false(row._removeButton._propagateMouseClicks)
+ assert.are.same({ "LeftButtonUp" }, row._removeButton._registeredClicks)
+ end)
+
+ it("does not re-enable editor row mouse targets during initializer state evaluation", function()
+ _G.CreateFrame = function(_, _, parent)
+ local frame = makeCollectionControl()
+ if parent and parent._children then
+ parent._children[#parent._children + 1] = frame
+ end
+ return frame
+ end
+
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local SB = lsb.New({
+ name = "Collections",
+ store = function()
+ return { root = {} }
+ end,
+ defaults = function()
+ return { root = {} }
+ end,
+ onChanged = function() end,
+ sections = {
+ {
+ key = "rows",
+ name = "Rows",
+ pages = {
+ {
+ key = "main",
+ rows = {
+ {
+ id = "listRow",
+ type = "list",
+ height = 120,
+ variant = "editor",
+ items = function()
+ return {
+ {
+ label = "Tick 1",
+ fields = {
+ { value = 50, min = 1, max = 100, step = 1 },
+ },
+ color = {
+ value = { r = 1, g = 1, b = 1, a = 1 },
+ },
+ remove = {
+ text = "Remove",
+ },
+ },
+ }
+ end,
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+ local initializer = SB:GetPage("rows", "main")._category:GetLayout()._initializers[1]
+ local host = makeCollectionControl()
+
+ initializer:InitFrame(host)
+
+ local row = host._lsbCollectionScrollBox._rows[1]
+ local slider = row._fieldWidgets[1].slider
+ assert.is_false(row._mouseEnabled)
+ assert.is_false(slider._propagateMouseClicks)
+ assert.is_false(slider._lsbValueButton._propagateMouseClicks)
+ assert.is_false(row._removeButton._propagateMouseClicks)
+ assert.is_true(host:IsShown())
+ assert.are.equal(1, host:GetAlpha())
+ end)
+
it("keeps mode-input submit disabled until the footer reports a valid value", function()
_G.CreateFrame = function()
return makeCollectionControl()
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index 0e0426e1..e12b7719 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -154,10 +154,18 @@ describe("PowerBarTickMarksOptions", function()
local captured, refreshCalls = registerPageSpec()
local tickCollection = getRow(captured, "tickCollection")
local item = tickCollection.items()[1]
+ local swatchColor
- item.color.onClick()
+ item.color.onClick(item, {
+ _swatch = {
+ SetColorRGB = function(_, r, g, b)
+ swatchColor = { r = r, g = g, b = b }
+ end,
+ },
+ })
local items = tickCollection.items()
assert.are.same(pickedColor, items[1].color.value)
+ assert.are.same({ r = 0.25, g = 0.5, b = 0.75 }, swatchColor)
item = items[1]
@@ -181,7 +189,7 @@ describe("PowerBarTickMarksOptions", function()
assert.are.equal(1, #items)
assert.are.equal(20, items[1].fields[1].value)
assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
- assert.are.equal(4, #refreshCalls)
+ assert.are.equal(1, #refreshCalls)
end)
it("rescales the value field range for large resource values", function()
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index 45d4111a..a37c00af 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -908,11 +908,6 @@ ExtraIconsOptions.pages = {
ctx.page:Refresh()
end,
},
- (function()
- local row = ns.OptionUtil.CreateFontOverrideRow(isDisabled)
- row.id = "fontOverride"
- return row
- end)(),
{
id = "viewers",
type = "sectionList",
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index 88fe7572..0e14b28f 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -126,6 +126,13 @@ local function scheduleUpdate()
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
+local function updatePickerSwatch(row, color)
+ local swatch = row and row._swatch or nil
+ if swatch and swatch.SetColorRGB then
+ swatch:SetColorRGB(color.r or 1, color.g or 1, color.b or 1)
+ end
+end
+
local function buildTickCollectionItems()
local ticks = getCurrentTicks()
local items = {}
@@ -149,7 +156,6 @@ local function buildTickCollectionItems()
onValueChanged = function(rounded)
updateTick(index, "value", rounded)
scheduleUpdate()
- refreshPage()
end,
},
{
@@ -163,18 +169,17 @@ local function buildTickCollectionItems()
onValueChanged = function(rounded)
updateTick(index, "width", rounded)
scheduleUpdate()
- refreshPage()
end,
},
},
color = {
value = tick.color or getDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR,
- onClick = function()
+ onClick = function(_, row)
local current = tick.color or getDefaultColor() or C.DEFAULT_POWERBAR_TICK_COLOR
ns.OptionUtil.OpenColorPicker(current, true, function(color)
updateTick(index, "color", color)
+ updatePickerSwatch(row, color)
scheduleUpdate()
- refreshPage()
end)
end,
},
From 9c73e5cca5aff7205d2dbefe7cf314cc1c304173 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 10:43:29 +1000
Subject: [PATCH 41/53] Fix defaults on several options pages.
---
.../Controls/CollectionFrames.lua | 9 +-
.../Primitives/BlizzardControls.lua | 23 ++--
.../Tests/Collections_spec.lua | 118 ++++++++++++++++++
Tests/TestHelpers.lua | 6 +
Tests/UI/ExtraIconsOptions_spec.lua | 59 +++++----
Tests/UI/PowerBarTickMarksOptions_spec.lua | 87 +++++++++++++
Tests/UI/ProfileOptions_spec.lua | 7 ++
UI/ExtraIconsOptions.lua | 7 ++
UI/PowerBarTickMarksOptions.lua | 24 ++++
UI/ProfileOptions.lua | 4 +
10 files changed, 315 insertions(+), 29 deletions(-)
diff --git a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
index c4fa65dd..910f5020 100644
--- a/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
+++ b/Libs/LibSettingsBuilder/Controls/CollectionFrames.lua
@@ -366,6 +366,13 @@ local function refreshEditorCollectionRow(row, item)
valueText:SetPoint("LEFT", slider, "RIGHT", 6, 0)
valueText:SetWidth(field.valueWidth or 40)
+ local rangeResolver
+ if field.getRange then
+ rangeResolver = function(targetValue)
+ return field.getRange(item, targetValue)
+ end
+ end
+
configureInlineSlider(slider, valueText, field, function(rounded)
if row._lsbRefreshing then
return
@@ -373,7 +380,7 @@ local function refreshEditorCollectionRow(row, item)
if field.onValueChanged then
field.onValueChanged(rounded, item, row)
end
- end)
+ end, rangeResolver)
preventMouseClickPropagation(slider)
previousValueText = valueText
diff --git a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
index 917c1158..5c167320 100644
--- a/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
+++ b/Libs/LibSettingsBuilder/Primitives/BlizzardControls.lua
@@ -231,15 +231,16 @@ local function attachInlineSliderEditor(slider, textLabel, editBoxWidth)
end)
end
-local function configureInlineSlider(slider, textLabel, field, onValueChanged)
+local function configureInlineSlider(slider, textLabel, field, onValueChanged, rangeResolver)
local minValue = field.min or 0
local maxValue = field.max or 1
local step = field.step or 1
+ slider._lsbOnValueChanged = onValueChanged
slider._lsbMinValue = minValue
slider._lsbMaxValue = maxValue
slider._lsbStep = step
- slider._lsbRangeResolver = field.getRange
+ slider._lsbRangeResolver = rangeResolver or field.getRange
if slider.MinText then
slider.MinText:Hide()
@@ -252,9 +253,17 @@ local function configureInlineSlider(slider, textLabel, field, onValueChanged)
end
if slider.Init then
- slider:Init(minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters())
- if slider.Slider and slider.Slider.SetValueStep then
- slider.Slider:SetValueStep(step)
+ local wasSuppressed = slider._lsbSuppressValueChanged
+ slider._lsbSuppressValueChanged = true
+ local ok, err = pcall(function()
+ slider:Init(field.value or minValue, minValue, maxValue, getSliderStepCount(minValue, maxValue, step), createInlineSliderFormatters())
+ if slider.Slider and slider.Slider.SetValueStep then
+ slider.Slider:SetValueStep(step)
+ end
+ end)
+ slider._lsbSuppressValueChanged = wasSuppressed
+ if not ok then
+ error(err, 0)
end
else
slider:SetMinMaxValues(minValue, maxValue)
@@ -270,8 +279,8 @@ local function configureInlineSlider(slider, textLabel, field, onValueChanged)
if textLabel and textLabel.SetText then
textLabel:SetText(tostring(rounded))
end
- if onValueChanged then
- onValueChanged(rounded)
+ if not slider._lsbSuppressValueChanged and slider._lsbOnValueChanged then
+ slider._lsbOnValueChanged(rounded)
end
end
diff --git a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
index efc4cf74..6cd2182f 100644
--- a/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
+++ b/Libs/LibSettingsBuilder/Tests/Collections_spec.lua
@@ -79,7 +79,13 @@ describe("LibSettingsBuilder Collections", function()
local function makeCollectionControl(clickedButtons)
local control = TestHelpers.makeFrame()
control._children = {}
+ local callbacks = {}
local textColor = { 1, 1, 1, 1 }
+ local function fireValueChanged(self, value)
+ for _, callback in ipairs(callbacks.OnValueChanged or {}) do
+ callback.fn(callback.owner or self, value)
+ end
+ end
control.SetShown = function(self, shown)
if shown then
self:Show()
@@ -148,9 +154,25 @@ describe("LibSettingsBuilder Collections", function()
control.SetObeyStepOnDrag = function(self, obey)
self._obeyStepOnDrag = obey
end
+ control.RegisterCallback = function(self, event, fn, owner)
+ callbacks[event] = callbacks[event] or {}
+ callbacks[event][#callbacks[event] + 1] = { fn = fn, owner = owner }
+ end
+ control.Init = function(self, initialValue, minValue, maxValue)
+ self._value = initialValue
+ self._minValue = minValue
+ self._maxValue = maxValue
+ fireValueChanged(self, initialValue)
+ end
control.SetValue = function(self, value)
self._value = value
+ fireValueChanged(self, value)
end
+ control.Slider = {
+ SetValueStep = function(_, step)
+ control._valueStep = step
+ end,
+ }
control.SetColorRGB = function(self, r, g, b)
self._color = { r, g, b }
end
@@ -414,6 +436,102 @@ describe("LibSettingsBuilder Collections", function()
assert.are.same({ "LeftButtonUp" }, row._removeButton._registeredClicks)
end)
+ it("keeps editor slider callbacks current across recycled row refreshes", function()
+ _G.CreateFrame = function()
+ return makeCollectionControl()
+ end
+
+ local calls = {}
+ local items = {
+ {
+ label = "Tick 1",
+ fields = {
+ {
+ value = 10,
+ min = 1,
+ max = 100,
+ step = 1,
+ onValueChanged = function(value)
+ calls[#calls + 1] = "first:" .. value
+ end,
+ },
+ },
+ },
+ }
+ local host = makeCollectionControl()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local data = {
+ preset = "editor",
+ rowHeight = 34,
+ items = function()
+ return items
+ end,
+ }
+
+ lsb._internal.applyCollectionFrame(host, data)
+ items = {
+ {
+ label = "Tick 2",
+ fields = {
+ {
+ value = 20,
+ min = 1,
+ max = 100,
+ step = 1,
+ onValueChanged = function(value)
+ calls[#calls + 1] = "second:" .. value
+ end,
+ },
+ },
+ },
+ }
+ lsb._internal.applyCollectionFrame(host, data)
+
+ host._lsbCollectionScrollBox._rows[1]._fieldWidgets[1].slider:SetValue(42)
+
+ assert.are.same({ "second:42" }, calls)
+ end)
+
+ it("resolves editor slider text entry ranges against the current item", function()
+ _G.CreateFrame = function()
+ return makeCollectionControl()
+ end
+
+ local resolvedItem
+ local host = makeCollectionControl()
+ local lsb = LibStub("LibSettingsBuilder-1.0")
+ local item = {
+ label = "Tick 1",
+ fields = {
+ {
+ value = 50,
+ min = 1,
+ max = 100,
+ step = 1,
+ getRange = function(currentItem, targetValue)
+ resolvedItem = currentItem
+ return 1, targetValue, 5
+ end,
+ },
+ },
+ }
+
+ lsb._internal.applyCollectionFrame(host, {
+ preset = "editor",
+ rowHeight = 34,
+ items = function()
+ return { item }
+ end,
+ })
+
+ local minValue, maxValue, step = host._lsbCollectionScrollBox._rows[1]._fieldWidgets[1].slider._lsbRangeResolver(500)
+
+ assert.are.equal(item, resolvedItem)
+ assert.are.equal(1, minValue)
+ assert.are.equal(500, maxValue)
+ assert.are.equal(5, step)
+ end)
+
it("does not re-enable editor row mouse targets during initializer state evaluation", function()
_G.CreateFrame = function(_, _, parent)
local frame = makeCollectionControl()
diff --git a/Tests/TestHelpers.lua b/Tests/TestHelpers.lua
index f06821f9..e105cef5 100644
--- a/Tests/TestHelpers.lua
+++ b/Tests/TestHelpers.lua
@@ -1680,6 +1680,9 @@ function TestHelpers.SetupPowerBarTickMarksEnv(opts)
Addon = {
db = {
profile = opts.profile or {},
+ defaults = {
+ profile = opts.defaults or {},
+ },
},
},
}
@@ -1702,6 +1705,9 @@ function TestHelpers.SetupPowerBarTickMarksEnv(opts)
return k
end })
ns.CloneValue = TestHelpers.deepClone
+ ns.Runtime = ns.Runtime or {
+ ScheduleLayoutUpdate = function() end,
+ }
ns.OptionUtil = {
GetCurrentClassSpec = opts.getCurrentClassSpec or function()
return 1, 2, "Warrior", "Fury", "WARRIOR"
diff --git a/Tests/UI/ExtraIconsOptions_spec.lua b/Tests/UI/ExtraIconsOptions_spec.lua
index f0b89ae2..f7ec9e06 100644
--- a/Tests/UI/ExtraIconsOptions_spec.lua
+++ b/Tests/UI/ExtraIconsOptions_spec.lua
@@ -42,9 +42,6 @@ describe("ExtraIconsOptions data helpers", function()
GetIsDisabledDelegate = function() return function() return false end end,
CreateModuleEnabledHandler = function() return function() end end,
MakeConfirmDialog = function() return {} end,
- CreateFontOverrideRow = function()
- return { type = "fontOverride" }
- end,
}
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
ExtraIconsOptions = ns.ExtraIconsOptions
@@ -777,7 +774,7 @@ end)
describe("ExtraIconsOptions settings page", function()
local originalGlobals
- local profile, defaults, SB, ns, capturedPage, registeredPage, refreshCalls, scheduledReasons, previewCalls, settings
+ local profile, defaults, SB, ns, capturedPage, registeredPage, refreshCalls, scheduledReasons, previewCalls
local function getRow(rowId)
local rows = assert(capturedPage and capturedPage.rows)
@@ -854,7 +851,6 @@ describe("ExtraIconsOptions settings page", function()
enabled = true,
showStackCount = true,
showCharges = true,
- overrideFont = false,
viewers = {
utility = {},
main = {},
@@ -869,7 +865,7 @@ describe("ExtraIconsOptions settings page", function()
previewCalls[#previewCalls + 1] = active
end
- settings = TestHelpers.CollectSettings(function()
+ TestHelpers.CollectSettings(function()
TestHelpers.LoadChunk("UI/ExtraIconsOptions.lua", "ExtraIconsOptions")(nil, ns)
capturedPage = ns.ExtraIconsOptions.pages[1]
local _, _, page = TestHelpers.RegisterSectionSpec(SB, ns.ExtraIconsOptions)
@@ -896,6 +892,12 @@ describe("ExtraIconsOptions settings page", function()
assert.are.same({ true, false }, previewCalls)
end)
+ it("registers page-level defaults for the section list state", function()
+ assert.is_function(capturedPage.onDefault)
+ assert.is_function(capturedPage.onDefaultEnabled)
+ assert.is_true(capturedPage.onDefaultEnabled())
+ end)
+
it("registers canonical rows and a section list instead of a canvas", function()
local opts = ns.ExtraIconsOptions
@@ -903,24 +905,14 @@ describe("ExtraIconsOptions settings page", function()
assert.are.equal("checkbox", getRow("enabled").type)
assert.are.equal("checkbox", getRow("showStackCount").type)
assert.are.equal("checkbox", getRow("showCharges").type)
- assert.are.equal("fontOverride", getRow("fontOverride").type)
+ assert.is_nil(getRow("fontOverride"))
assert.are.equal("sectionList", getRow("viewers").type)
assert.are.equal(4, getRow("viewers").footerSpacing)
- assert.are.equal(5, #capturedPage.rows)
+ assert.are.equal(4, #capturedPage.rows)
+ assert.are.equal("enabled", capturedPage.rows[1].id)
+ assert.are.equal("showStackCount", capturedPage.rows[2].id)
assert.are.equal("showCharges", capturedPage.rows[3].id)
- assert.are.equal("fontOverride", capturedPage.rows[4].id)
- assert.are.equal("viewers", capturedPage.rows[5].id)
- end)
-
- it("schedules layout when count font settings change", function()
- settings["ECM_extraIcons_overrideFont"]:SetValue(true)
- settings["ECM_extraIcons_font"]:SetValue("Expressway")
- settings["ECM_extraIcons_fontSize"]:SetValue(18)
-
- assert.is_true(profile.extraIcons.overrideFont)
- assert.are.equal("Expressway", profile.extraIcons.font)
- assert.are.equal(18, profile.extraIcons.fontSize)
- assert.are.same({ "OptionsChanged", "OptionsChanged", "OptionsChanged" }, scheduledReasons)
+ assert.are.equal("viewers", capturedPage.rows[4].id)
end)
it("builds utility and main sections with placeholder rows and footers", function()
@@ -939,6 +931,31 @@ describe("ExtraIconsOptions settings page", function()
end))
end)
+ it("defaults button restores extra icon settings and clears draft input", function()
+ profile.extraIcons.showStackCount = false
+ profile.extraIcons.viewers.utility = {
+ { kind = "spell", ids = { 12345 } },
+ }
+ defaults.extraIcons.showStackCount = true
+ defaults.extraIcons.viewers.utility = {
+ { stackKey = "trinket1" },
+ }
+
+ local utilityFooter = assert(getSection("utility").footer)
+ utilityFooter.onTextChanged("98765")
+ utilityFooter.onToggleMode()
+
+ capturedPage.onDefault()
+
+ assert.is_true(profile.extraIcons.showStackCount)
+ assert.are.same({ { stackKey = "trinket1" } }, profile.extraIcons.viewers.utility)
+ utilityFooter = assert(getSection("utility").footer)
+ assert.are.equal("Spell", utilityFooter.modeText())
+ assert.are.equal("", utilityFooter.inputText())
+ assert.are.same({ "OptionsChanged" }, scheduledReasons)
+ assert.are.same({ registeredPage._category }, refreshCalls)
+ end)
+
it("keeps active racial entries fully enabled after replacing the placeholder", function()
_G.UnitRace = function() return "Night Elf", "NightElf", 4 end
_G.C_Spell = {
diff --git a/Tests/UI/PowerBarTickMarksOptions_spec.lua b/Tests/UI/PowerBarTickMarksOptions_spec.lua
index e12b7719..36b59b6f 100644
--- a/Tests/UI/PowerBarTickMarksOptions_spec.lua
+++ b/Tests/UI/PowerBarTickMarksOptions_spec.lua
@@ -192,6 +192,93 @@ describe("PowerBarTickMarksOptions", function()
assert.are.equal(1, #refreshCalls)
end)
+ it("defaults button restores the current spec's default tick marks", function()
+ local scheduledReasons = {}
+ local defaultColor = { r = 0.9, g = 0.8, b = 0.7, a = 0.6 }
+
+ currentClassID = 12
+ currentSpecIndex = 3
+ ns.Addon.db.defaults.profile.powerBar = {
+ ticks = {
+ mappings = {
+ [12] = {
+ [3] = {
+ { value = 90, color = { r = 2 / 3, g = 2 / 3, b = 2 / 3, a = 0.8 } },
+ { value = 100 },
+ },
+ },
+ },
+ defaultColor = defaultColor,
+ defaultWidth = 2,
+ },
+ }
+ setTickMappings({
+ [12] = {
+ [3] = {
+ { value = 30, width = 5, color = { r = 1, g = 0, b = 0, a = 1 } },
+ },
+ },
+ })
+ ns.Runtime = {
+ ScheduleLayoutUpdate = function(_, reason)
+ scheduledReasons[#scheduledReasons + 1] = reason
+ end,
+ }
+
+ local captured, refreshCalls = registerPageSpec()
+
+ captured.onDefault()
+
+ local items = getRow(captured, "tickCollection").items()
+ assert.are.equal(2, #items)
+ assert.are.equal(90, items[1].fields[1].value)
+ assert.are.same({ r = 2 / 3, g = 2 / 3, b = 2 / 3, a = 0.8 }, items[1].color.value)
+ assert.are.equal(2, items[1].fields[2].value)
+ assert.are.equal(100, items[2].fields[1].value)
+ assert.are.same(defaultColor, getRow(captured, "defaultColor").get())
+ assert.are.equal(2, getRow(captured, "defaultWidth").get())
+ assert.are.same({ "OptionsChanged" }, scheduledReasons)
+ assert.are.same({ true }, refreshCalls)
+ end)
+
+ it("defaults button clears custom ticks when the current spec has no defaults", function()
+ local scheduledReasons = {}
+
+ ns.Addon.db.defaults.profile.powerBar = {
+ ticks = {
+ mappings = {
+ [12] = {
+ [3] = {
+ { value = 90 },
+ },
+ },
+ },
+ defaultColor = ns.Constants.DEFAULT_POWERBAR_TICK_COLOR,
+ defaultWidth = 1,
+ },
+ }
+ setTickMappings({
+ [1] = {
+ [2] = {
+ { value = 60, width = 4, color = { r = 1, g = 0, b = 0, a = 1 } },
+ },
+ },
+ })
+ ns.Runtime = {
+ ScheduleLayoutUpdate = function(_, reason)
+ scheduledReasons[#scheduledReasons + 1] = reason
+ end,
+ }
+
+ local captured, refreshCalls = registerPageSpec()
+
+ captured.onDefault()
+
+ assert.are.same({}, getRow(captured, "tickCollection").items())
+ assert.are.same({ "OptionsChanged" }, scheduledReasons)
+ assert.are.same({ true }, refreshCalls)
+ end)
+
it("rescales the value field range for large resource values", function()
setTickMappings({
[1] = {
diff --git a/Tests/UI/ProfileOptions_spec.lua b/Tests/UI/ProfileOptions_spec.lua
index 1181beb6..43ed021c 100644
--- a/Tests/UI/ProfileOptions_spec.lua
+++ b/Tests/UI/ProfileOptions_spec.lua
@@ -41,6 +41,8 @@ describe("ProfileOptions getters/setters/defaults", function()
assert.are.equal("profile", ns.ProfileOptions.key)
assert.are.equal("Other", settings.ECM_ProfileCopy:GetValue())
assert.is_not_nil(profileCategory)
+ assert.is_function(ns.ProfileOptions.pages[1].onDefault)
+ assert.is_false(ns.ProfileOptions.pages[1].onDefaultEnabled())
end)
before_each(function()
@@ -74,6 +76,11 @@ describe("ProfileOptions getters/setters/defaults", function()
end
describe("switch profile", function()
+ it("disables the category defaults button for profile actions", function()
+ assert.is_function(ns.ProfileOptions.pages[1].onDefault)
+ assert.is_false(ns.ProfileOptions.pages[1].onDefaultEnabled())
+ end)
+
it("getter returns current profile", function()
assert.are.equal("Default", getSetting("ProfileSwitch"):GetValue())
end)
diff --git a/UI/ExtraIconsOptions.lua b/UI/ExtraIconsOptions.lua
index a37c00af..42393cd7 100644
--- a/UI/ExtraIconsOptions.lua
+++ b/UI/ExtraIconsOptions.lua
@@ -859,12 +859,19 @@ function ExtraIconsOptions.ResetToDefaults()
doAction()
end
+local function canResetToDefaults()
+ local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ return defaults and defaults.extraIcons ~= nil
+end
+
ExtraIconsOptions.key = "extraIcons"
ExtraIconsOptions.name = L["EXTRA_ICONS"]
ExtraIconsOptions.pages = {
{
key = "main",
+ onDefault = ExtraIconsOptions.ResetToDefaults,
+ onDefaultEnabled = canResetToDefaults,
onShow = function()
ns.Runtime.SetLayoutPreview(true)
ExtraIconsOptions.EnsureItemLoadFrame()
diff --git a/UI/PowerBarTickMarksOptions.lua b/UI/PowerBarTickMarksOptions.lua
index 0e14b28f..2b7d723f 100644
--- a/UI/PowerBarTickMarksOptions.lua
+++ b/UI/PowerBarTickMarksOptions.lua
@@ -33,6 +33,11 @@ local function getTicksConfig()
return ticks
end
+local function getDefaultTicksConfig()
+ local defaults = ns.Addon.db and ns.Addon.db.defaults and ns.Addon.db.defaults.profile
+ return defaults and defaults.powerBar and defaults.powerBar.ticks or nil
+end
+
local function getCurrentTicks()
local classID, specIndex = ns.OptionUtil.GetCurrentClassSpec()
if not classID or not specIndex then
@@ -133,6 +138,24 @@ local function updatePickerSwatch(row, color)
end
end
+local function resetToDefaults()
+ local ticksCfg = getTicksConfig()
+ local defaultTicksCfg = getDefaultTicksConfig()
+
+ ticksCfg.defaultColor = ns.CloneValue((defaultTicksCfg and defaultTicksCfg.defaultColor) or C.DEFAULT_POWERBAR_TICK_COLOR)
+ ticksCfg.defaultWidth = (defaultTicksCfg and defaultTicksCfg.defaultWidth) or 1
+
+ local classID, specIndex = ns.OptionUtil.GetCurrentClassSpec()
+ if classID and specIndex then
+ local classDefaults = defaultTicksCfg and defaultTicksCfg.mappings and defaultTicksCfg.mappings[classID]
+ local specDefaults = classDefaults and classDefaults[specIndex] or {}
+ setCurrentTicks(ns.CloneValue(specDefaults))
+ end
+
+ scheduleUpdate()
+ refreshPage()
+end
+
local function buildTickCollectionItems()
local ticks = getCurrentTicks()
local items = {}
@@ -199,6 +222,7 @@ end
PowerBarTickMarksOptions.key = "tickMarks"
PowerBarTickMarksOptions.name = "Tick Marks"
+PowerBarTickMarksOptions.onDefault = resetToDefaults
PowerBarTickMarksOptions.rows = {
{
id = "description",
diff --git a/UI/ProfileOptions.lua b/UI/ProfileOptions.lua
index 7b1ea3f3..f954f090 100644
--- a/UI/ProfileOptions.lua
+++ b/UI/ProfileOptions.lua
@@ -145,6 +145,10 @@ ProfileOptions.name = L["PROFILES"]
ProfileOptions.pages = {
{
key = "main",
+ onDefault = function() end,
+ onDefaultEnabled = function()
+ return false
+ end,
rows = {
{ type = "header", name = L["ACTIVE_PROFILE"] },
{
From 3e1c122b6f0987f44630214eafde5f2d42044642 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 11:03:05 +1000
Subject: [PATCH 42/53] Add confirmation dialog with defaults.
---
Libs/LibSettingsBuilder/Core.lua | 28 ++++++++++++----
Libs/LibSettingsBuilder/Utility.lua | 5 ++-
Locales/en.lua | 3 ++
Tests/UI/Options_spec.lua | 52 +++++++++++++++++++++++++++++
UI/OptionUtil.lua | 27 +++++++++++++++
UI/Options.lua | 3 ++
6 files changed, 111 insertions(+), 7 deletions(-)
diff --git a/Libs/LibSettingsBuilder/Core.lua b/Libs/LibSettingsBuilder/Core.lua
index dce4aa92..cf244c07 100644
--- a/Libs/LibSettingsBuilder/Core.lua
+++ b/Libs/LibSettingsBuilder/Core.lua
@@ -46,6 +46,7 @@
---@field onChanged LibSettingsBuilderChangedCallback Gets the callback fired after a row setter completes.
---@field store table|(fun(): table)|nil Gets the store table or lazy provider used by path-bound rows.
---@field defaults table|(fun(): table)|nil Gets the defaults table or lazy provider used by path-bound rows.
+---@field defaultsConfirmation fun(pageName: string, onAccept: fun())|nil Gets the optional confirmation hook shown before any category-header `Defaults` reset.
---@field getNestedValue LibSettingsBuilderGetNestedValue|nil Gets the custom nested-path reader used by path-bound rows.
---@field setNestedValue LibSettingsBuilderSetNestedValue|nil Gets the custom nested-path writer used by path-bound rows.
---@field page LibSettingsBuilderPageConfig|nil Gets the optional root-owned page definition.
@@ -77,7 +78,7 @@ end
--- long as the override is active. Returns a restore function the caller must
--- invoke when the page is hidden so other categories keep Blizzard's default
--- behavior.
-function internal.installCategoryDefaultsOverride(onClick, enabledPredicate)
+function internal.installCategoryDefaultsOverride(onClick, enabledPredicate, confirmDefaults, pageName)
local button = getCategoryDefaultsButton()
if not button then
return function() end
@@ -89,17 +90,32 @@ function internal.installCategoryDefaultsOverride(onClick, enabledPredicate)
local function applyEnabled()
if enabledPredicate then
button:SetEnabled(enabledPredicate() and true or false)
+ elseif not onClick then
+ button:SetEnabled(originalEnabled)
else
button:SetEnabled(true)
end
end
- button:SetScript("OnClick", function()
+ button:SetScript("OnClick", function(self)
if enabledPredicate and not enabledPredicate() then
return
end
- onClick()
- applyEnabled()
+
+ local function reset()
+ if onClick then
+ onClick()
+ applyEnabled()
+ elseif originalOnClick then
+ originalOnClick(self)
+ end
+ end
+
+ if confirmDefaults then
+ confirmDefaults(pageName, reset)
+ else
+ reset()
+ end
end)
applyEnabled()
@@ -163,8 +179,8 @@ local function installPageLifecycleHooks() if lib._pageLifecycleHooked then
if category then
local cbs = lib._pageLifecycleCallbacks[category]
if cbs then
- if cbs.onDefault then
- cbs._defaultsRestore = internal.installCategoryDefaultsOverride(cbs.onDefault, cbs.onDefaultEnabled)
+ if cbs.onDefault or cbs.confirmDefaults then
+ cbs._defaultsRestore = internal.installCategoryDefaultsOverride(cbs.onDefault, cbs.onDefaultEnabled, cbs.confirmDefaults, cbs.pageName)
end
if cbs.onShow then
cbs.onShow()
diff --git a/Libs/LibSettingsBuilder/Utility.lua b/Libs/LibSettingsBuilder/Utility.lua
index 7db041ca..55ed75df 100644
--- a/Libs/LibSettingsBuilder/Utility.lua
+++ b/Libs/LibSettingsBuilder/Utility.lua
@@ -651,12 +651,15 @@ local function assertPageMutable(page, sourceName)
end
local function bindPageLifecycle(page)
- if page._onShow or page._onHide or page._onDefault then
+ local confirmDefaults = page._builder._config.defaultsConfirmation
+ if page._onShow or page._onHide or page._onDefault or confirmDefaults then
lib._pageLifecycleCallbacks[page._category] = {
onShow = page._onShow,
onHide = page._onHide,
onDefault = page._onDefault,
onDefaultEnabled = page._onDefaultEnabled,
+ confirmDefaults = confirmDefaults,
+ pageName = page._name,
}
installPageLifecycleHooks()
end
diff --git a/Locales/en.lua b/Locales/en.lua
index e326bd19..693db120 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -342,6 +342,9 @@ L["DELETE"] = "Delete"
L["DELETE_DESC"] = "Delete the selected profile. The active profile cannot be deleted."
L["DELETE_PROFILE_CONFIRM"] = "Are you sure you want to delete the profile '%s'?"
L["RESET"] = "Reset"
+L["RESET_PAGE_CONFIRM"] = "Are you sure you want to reset settings on this page?"
+L["DONT_RESET"] = "Don't reset"
+L["RESET_PAGE_SETTINGS"] = "Reset %s settings"
L["RESET_PROFILE"] = "Reset current profile to defaults"
L["RESET_PROFILE_BUTTON"] = "Reset Profile"
L["RESET_PROFILE_DESC"] = "Reset the current profile back to default settings. This cannot be undone."
diff --git a/Tests/UI/Options_spec.lua b/Tests/UI/Options_spec.lua
index 4174391b..a72a7aa6 100644
--- a/Tests/UI/Options_spec.lua
+++ b/Tests/UI/Options_spec.lua
@@ -449,5 +449,57 @@ describe("OptionUtil", function()
assert.are.equal(profileCategory:GetID(), openedCategory)
end)
+
+ it("confirms native page defaults before invoking the header reset", function()
+ local nativeResetCalls = 0
+ local popupKey
+ local popupText
+ local acceptText
+ local cancelText
+ local acceptFn
+ local button = {
+ _script = function()
+ nativeResetCalls = nativeResetCalls + 1
+ end,
+ _enabled = true,
+ GetScript = function(self)
+ return self._script
+ end,
+ SetScript = function(self, _, script)
+ self._script = script
+ end,
+ IsEnabled = function(self)
+ return self._enabled
+ end,
+ SetEnabled = function(self, enabled)
+ self._enabled = enabled
+ end,
+ }
+
+ rawset(SettingsPanel, "GetSettingsList", function()
+ return { Header = { DefaultsButton = button } }
+ end)
+ ns.Addon.ShowConfirmDialog = function(_, key, text, button1, button2, onAccept)
+ popupKey = key
+ popupText = text
+ acceptText = button1
+ cancelText = button2
+ acceptFn = onAccept
+ end
+
+ SettingsPanel:SetCurrentCategory(generalCategory)
+ SettingsPanel:DisplayCategory(generalCategory)
+ button:GetScript("OnClick")(button)
+
+ assert.are.equal(0, nativeResetCalls)
+ assert.are.equal("ECM_CONFIRM_RESET_SETTINGS_PAGE", popupKey)
+ assert.are.equal("Are you sure you want to reset settings on this page?", popupText)
+ assert.are.equal("Reset General settings", acceptText)
+ assert.are.equal("Don't reset", cancelText)
+
+ acceptFn()
+
+ assert.are.equal(1, nativeResetCalls)
+ end)
end)
end)
diff --git a/UI/OptionUtil.lua b/UI/OptionUtil.lua
index 6c1d5949..4fb9033e 100644
--- a/UI/OptionUtil.lua
+++ b/UI/OptionUtil.lua
@@ -491,3 +491,30 @@ function OptionUtil.MakeConfirmDialog(text)
hideOnEscape = true,
}
end
+
+local function formatResetPageButton(pageName)
+ local format = L["RESET_PAGE_SETTINGS"]
+ if type(format) ~= "string" or not format:find("%%s") then
+ format = "Reset %s settings"
+ end
+ return format:format(pageName or "")
+end
+
+function OptionUtil.ConfirmPageDefaultsReset(pageName, onAccept)
+ local addon = ns.Addon
+ if addon and addon.ShowConfirmDialog then
+ addon:ShowConfirmDialog(
+ "ECM_CONFIRM_RESET_SETTINGS_PAGE",
+ L["RESET_PAGE_CONFIRM"],
+ formatResetPageButton(pageName),
+ L["DONT_RESET"],
+ onAccept
+ )
+ return
+ end
+
+ StaticPopupDialogs["ECM_CONFIRM_RESET_SETTINGS_PAGE"] = OptionUtil.MakeConfirmDialog(L["RESET_PAGE_CONFIRM"])
+ StaticPopupDialogs["ECM_CONFIRM_RESET_SETTINGS_PAGE"].button1 = formatResetPageButton(pageName)
+ StaticPopupDialogs["ECM_CONFIRM_RESET_SETTINGS_PAGE"].button2 = L["DONT_RESET"]
+ StaticPopup_Show("ECM_CONFIRM_RESET_SETTINGS_PAGE", nil, nil, { onAccept = onAccept })
+end
diff --git a/UI/Options.lua b/UI/Options.lua
index c7751cde..c984ba80 100644
--- a/UI/Options.lua
+++ b/UI/Options.lua
@@ -24,6 +24,9 @@ ns.Settings = LSB:New({
ns.Runtime.ScheduleLayoutUpdate(0, "OptionsChanged")
end
end,
+ defaultsConfirmation = function(pageName, onAccept)
+ ns.OptionUtil.ConfirmPageDefaultsReset(pageName, onAccept)
+ end,
})
ns.SettingsBuilder = ns.Settings
From cbd90230b82066ae0ab133e06cec27455721bce1 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 12:13:38 +1000
Subject: [PATCH 43/53] Update README.md
---
README.md | 55 +++++++++++++++++--------------------------------------
1 file changed, 17 insertions(+), 38 deletions(-)
diff --git a/README.md b/README.md
index 16f19d2d..05a229fd 100644
--- a/README.md
+++ b/README.md
@@ -1,31 +1,24 @@
# Enhanced Cooldown Manager by Argium
-Enhanced Cooldown Manager creates a clean combat HUD around Blizzard's built-in cooldown manager that **looks and works great out of the box** and is **straightforward to customise.** It adds a mana/power bar and resource bar, extra icons for potions, and supports per-spell color and customisation for aura bars. Its modular design allows each part to be attached to the CDM or detached and freely placed.
+ECM enriches the built-in Cooldown Manager with new features including power and resource bars, customisable-per-spell aura bars, and extra spell and item icons. It designed to be reminscent of the pre-Midnight WeakAura HUDs, and it **looks and works great out of the box** while being **straightforward to customise.** Each set of bars can be enabled or disabled, and detached and moved independently from others. Icons for health and combat potions are included by default and new ones can be added for racials, equipment, jumper cables - anything you want.
-Made with ❤️, with little features you won't want to live without.
+Made with ❤️.
-## Features
+### Features
-### ⚔️ Inline Resources
+#### Power and Resources Bars
-Adds essential combat bars directly below Blizzard's cooldown manager.
+Adds bars for resources: mana, rage, energy, focus, fury, runic power
+Or special class resources such as holy power, runes, and souls.
-- `Power Bar` for mana, rage, energy, focus, and runic power
-- `Resource Bar` for class resources
-- `Rune Bar` for Death Knight rune tracking
-- `Aura Bars` with unified style and color control
+#### Aura Bars
-
-
-### 🎨 Aura Bars
+Style the default aura bars to match power and resource bars.
+Customise their colours **on a per-spell basis** to quickly idenify their remaining duration mid-combat.
-Automatically position aura bars and style them to match. Change their colors for different spells so you can see their remaining duration at a glance.
-
-
+#### Auto-hide and fade
-### 🙈 Smart Visibility and Fade Rules
-
-Reduce screen clutter automatically based on gameplay context:
+Reduce screen clutter based based on context:
- Hide while mounted or in a vehicle
- Hide in rest areas
@@ -33,36 +26,22 @@ Reduce screen clutter automatically based on gameplay context:
- Optionally stay visible in instances (raids, M+, PVP)
- Optionally stay visible when you have an attackable target
+

+
-### 🟥 Death Knight Runes
-
-Track each rune independently as it recharges inline with other resources and cooldowns.
-
-
-
-### 🧪 Add Icons for Trinkets, Potions, and Healthstones
-
-Extend the utility cooldown bar with essential combat icons to save you a glance at the action bar.
+#### Add Icons for any Spell or Item
-- Equipped trinket cooldowns
-- Health potion cooldown
-- Combat potion cooldown
-- Healthstone cooldown
+Add and icon to either the main or utility bars for **any** item or spell id: healthstones, potions, jumper cables, racials, anything!
-### 📌 Automatic positioning or free movement
+### Automatic or free positioning
-Use the layout mode that fits your setup.
+Use the layout mode that fits your setup:
- Auto-position directly under Blizzard's Cooldown Manager
- Detach modules and move them independently
- Mix and match layouts depending on preference
-## Installation
-
-1. Download and extract this addon into `World of Warcraft/_retail_/Interface/AddOns`.
-2. Reload your UI or restart the game.
-
## Configuration
- Use `/ecm` in game to open options.
From 956bc08c79fcb13669858324c1f12646632b6c02 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:18:28 +1000
Subject: [PATCH 44/53] Refactor README for clarity and formatting
Improved readability and fixed minor grammatical issues in the README.
---
README.md | 38 +++++++++++++++++++-------------------
1 file changed, 19 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 05a229fd..e43776a1 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,40 @@
# Enhanced Cooldown Manager by Argium
-ECM enriches the built-in Cooldown Manager with new features including power and resource bars, customisable-per-spell aura bars, and extra spell and item icons. It designed to be reminscent of the pre-Midnight WeakAura HUDs, and it **looks and works great out of the box** while being **straightforward to customise.** Each set of bars can be enabled or disabled, and detached and moved independently from others. Icons for health and combat potions are included by default and new ones can be added for racials, equipment, jumper cables - anything you want.
+ECM enriches the built-in Cooldown Manager with new features, including power and resource bars, customisable per-spell aura bars, and extra spell and item icons. It's designed to be reminiscent of the pre-Midnight WeakAura HUDs, and it **looks and works great out of the box** while being **straightforward to customise**.
-Made with ❤️.
+Each set of bars can be enabled or disabled, detached, and moved independently from the others. Icons for health and combat potions are included by default, and new ones can be added for racials, equipment, jumper cables — anything you want.
-### Features
+Made with ❤️
-#### Power and Resources Bars
+### Features
-Adds bars for resources: mana, rage, energy, focus, fury, runic power
-Or special class resources such as holy power, runes, and souls.
+#### Power and Resource Bars
+
+Adds bars for resources: mana, rage, energy, focus, fury, and runic power.
+
+Also supports special class resources such as holy power, runes, and soul shards.
#### Aura Bars
-Style the default aura bars to match power and resource bars.
-Customise their colours **on a per-spell basis** to quickly idenify their remaining duration mid-combat.
+Styles the default aura bars to match power and resource bars.
-#### Auto-hide and fade
+Customise their colours **on a per-spell basis** to quickly identify their remaining duration mid-combat.
-Reduce screen clutter based based on context:
+#### Auto-hide and Fade
+
+Reduce screen clutter based on context:
- Hide while mounted or in a vehicle
- Hide in rest areas
- Fade when out of combat
-- Optionally stay visible in instances (raids, M+, PVP)
+- Optionally stay visible in instances: raids, M+, and PvP
- Optionally stay visible when you have an attackable target
-
-
-
-
-#### Add Icons for any Spell or Item
+#### Add Icons for Any Spell or Item
-Add and icon to either the main or utility bars for **any** item or spell id: healthstones, potions, jumper cables, racials, anything!
+Add an icon to either the main or utility bars for **any** item or spell ID: healthstones, potions, jumper cables, racials — anything.
-### Automatic or free positioning
+#### Automatic or Free Positioning
Use the layout mode that fits your setup:
@@ -44,7 +44,7 @@ Use the layout mode that fits your setup:
## Configuration
-- Use `/ecm` in game to open options.
+- Use `/ecm` in-game to open options.
- You can also open it from the AddOn compartment menu near the minimap.
## License
From cc9031067b97cfd23769da19be6877abc19872ef Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:27:07 +1000
Subject: [PATCH 45/53] Update TOC
Updated interface and notes for Enhanced Cooldown Manager.
---
EnhancedCooldownManager.toc | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 4161ea6c..3aa1dfb1 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -1,10 +1,9 @@
-## Interface: 120000, 120001, 120005, 110207
+## Interface: 120000, 120001, 120005
## Title: Enhanced Cooldown Manager |cff9c9c9cby|r |cffa855f7A|r|cff7a84f7r|r|cff4cc9f0g|r|cff22c55ei|r
-## Notes: Standalone resource bars anchored to Blizzard's Cooldown Manager.
+## Notes: Add resource, power and buff bars, new spells and item icons to the built-in Cooldown Manager. Use all or one. Easy to use. Detachable. Customisable aura bar colours.
## Author: Argi
## Version: v0.8.0-beta1
## SavedVariables: EnhancedCooldownManagerDB
-## OptionalDeps: Ace3, LibSharedMedia-3.0
## Category-enUS: User Interface
## IconTexture: Interface\AddOns\EnhancedCooldownManager\Media\icon
## X-Wago-ID: qGZOwbNd
From 72d7d1a26cf3cce393b9de93078810534a7209f4 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 15:27:28 +1000
Subject: [PATCH 46/53] Update notes in EnhancedCooldownManager.toc
Removed mention of detachable and customizable aura bar colors from notes.
---
EnhancedCooldownManager.toc | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index 3aa1dfb1..a3fb2bf7 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -1,6 +1,6 @@
## Interface: 120000, 120001, 120005
## Title: Enhanced Cooldown Manager |cff9c9c9cby|r |cffa855f7A|r|cff7a84f7r|r|cff4cc9f0g|r|cff22c55ei|r
-## Notes: Add resource, power and buff bars, new spells and item icons to the built-in Cooldown Manager. Use all or one. Easy to use. Detachable. Customisable aura bar colours.
+## Notes: Add resource, power and buff bars, new spells and item icons to the built-in Cooldown Manager.
## Author: Argi
## Version: v0.8.0-beta1
## SavedVariables: EnhancedCooldownManagerDB
From 1c6ddca914a5276e9fb1c1aa70a3c66281f49346 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 30 Apr 2026 09:30:15 +0000
Subject: [PATCH 47/53] Add safety guards for LibEvent re-embed initialization
Agent-Logs-Url: https://github.com/argium/EnhancedCooldownManager/sessions/6c5181b9-9291-4f96-9a96-9a17f3c671b6
Co-authored-by: argium <15852038+argium@users.noreply.github.com>
---
Libs/LibEvent/LibEvent.lua | 3 +++
1 file changed, 3 insertions(+)
diff --git a/Libs/LibEvent/LibEvent.lua b/Libs/LibEvent/LibEvent.lua
index 5295fbb5..e5955c94 100644
--- a/Libs/LibEvent/LibEvent.lua
+++ b/Libs/LibEvent/LibEvent.lua
@@ -106,6 +106,9 @@ local function createInstance(target)
instance = { _events = {}, _stats = {} }
end
+ instance._events = instance._events or {}
+ instance._stats = instance._stats or {}
+
instance.frame = instance.frame or CreateFrame("Frame")
-- Dispatch without snapshot: use index-based iteration that tolerates
From 5c35b6ce6ec412df6e7bdfbe3e886974536dbf8d Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Thu, 30 Apr 2026 18:25:15 +1000
Subject: [PATCH 48/53] Update release notes and command help
---
Constants.lua | 2 +-
ECM.lua | 6 ++-
Libs/LibEvent/LibEvent.lua | 20 ++++++--
Libs/LibEvent/Tests/LibEvent_spec.lua | 67 ++++-----------------------
Locales/en.lua | 32 ++++++-------
README.md | 12 +++--
Tests/ChatCommand_spec.lua | 38 ++++++++++++---
7 files changed, 84 insertions(+), 93 deletions(-)
diff --git a/Constants.lua b/Constants.lua
index c9518126..20b82739 100644
--- a/Constants.lua
+++ b/Constants.lua
@@ -9,7 +9,7 @@ local constants = {
ADDON_ICON_TEXTURE = "Interface\\AddOns\\EnhancedCooldownManager\\Media\\icon",
ADDON_METADATA_VERSION_KEY = "Version",
DEBUG_COLOR = "F17934",
- RELEASE_POPUP_VERSION = "v0.7.1",
+ RELEASE_POPUP_VERSION = "v0.8",
VERSION_TAG_BETA = "beta",
-- Module identifiers
diff --git a/ECM.lua b/ECM.lua
index a11241bb..f0dbf1da 100644
--- a/ECM.lua
+++ b/ECM.lua
@@ -184,12 +184,15 @@ end
function mod:ChatCommand(input)
local cmd, arg = (input or ""):lower():match("^%s*(%S*)%s*(.-)%s*$")
- if cmd == "help" then
+ if cmd == "help" or cmd == "h" then
ns.Print(L["CMD_HELP_CLEARSEEN"])
ns.Print(L["CMD_HELP_DEBUG"])
ns.Print(L["CMD_HELP_EVENTS"])
+ ns.Print(L["CMD_HELP_EVENTS_RESET"])
ns.Print(L["CMD_HELP_HELP"])
ns.Print(L["CMD_HELP_MIGRATION"])
+ ns.Print(L["CMD_HELP_MIGRATION_LOG"])
+ ns.Print(L["CMD_HELP_MIGRATION_ROLLBACK"])
ns.Print(L["CMD_HELP_OPTIONS"])
ns.Print(L["CMD_HELP_REFRESH"])
return
@@ -285,6 +288,7 @@ function mod:ChatCommand(input)
if cmd == "clearseen" then
gc.releasePopupSeenVersion = nil
ns.Print(L["SEEN_CLEARED"])
+ ReloadUI()
return
end
end
diff --git a/Libs/LibEvent/LibEvent.lua b/Libs/LibEvent/LibEvent.lua
index e5955c94..3fa2a934 100644
--- a/Libs/LibEvent/LibEvent.lua
+++ b/Libs/LibEvent/LibEvent.lua
@@ -3,7 +3,7 @@
-- Licensed under the GNU General Public License v3.0
---@class LibEvent
----@field embeds table, _stats: table }> Stores embedded event instances by target table.
+---@field embeds table, _stats: table|nil }> Stores embedded event instances by target table.
local MAJOR, MINOR = "LibEvent-1.0", 3
local LibEvent = LibStub:NewLibrary(MAJOR, MINOR)
@@ -17,6 +17,9 @@ local pairs = pairs
local type = type
local wipe = wipe
+local METRICS_DEBUG_ENABLED = false
+local EMPTY_STATS = {}
+
LibEvent.embeds = LibEvent.embeds or {}
local function getInstance(target)
@@ -92,18 +95,23 @@ end
---Gets the event invocation stats for this embedded target.
---@return table A table mapping event names to their fire counts.
function LibEvent:GetEventStats()
- return getInstance(self)._stats
+ return getInstance(self)._stats or EMPTY_STATS
end
---Resets the event invocation stats for this embedded target.
function LibEvent:ResetEventStats()
- wipe(getInstance(self)._stats)
+ local stats = getInstance(self)._stats
+ if stats then wipe(stats) end
end
local function createInstance(target)
local instance = LibEvent.embeds[target]
if type(instance) ~= "table" then
- instance = { _events = {}, _stats = {} }
+ instance = { _events = {} }
+ end
+
+ if METRICS_DEBUG_ENABLED and not instance._stats then
+ instance._stats = {}
end
instance._events = instance._events or {}
@@ -119,7 +127,9 @@ local function createInstance(target)
if not cbs then
return
end
- instance._stats[event] = (instance._stats[event] or 0) + 1
+ if METRICS_DEBUG_ENABLED then
+ instance._stats[event] = (instance._stats[event] or 0) + 1
+ end
instance._dispatching = true
local i = 1
while i <= #cbs do
diff --git a/Libs/LibEvent/Tests/LibEvent_spec.lua b/Libs/LibEvent/Tests/LibEvent_spec.lua
index db40d3c6..b2edff1a 100644
--- a/Libs/LibEvent/Tests/LibEvent_spec.lua
+++ b/Libs/LibEvent/Tests/LibEvent_spec.lua
@@ -351,13 +351,13 @@ describe("LibEvent", function()
assert.same({ "stable" }, calls)
end)
- it("initializes _stats as an empty table", function()
+ it("does not initialize _stats when metrics debug is disabled", function()
local target = {}
LibEvent:Embed(target)
- assert.same({}, LibEvent.embeds[target]._stats)
+ assert.is_nil(LibEvent.embeds[target]._stats)
end)
- it("increments _stats on each event fire", function()
+ it("does not increment _stats when metrics debug is disabled", function()
local target = { TEST_EVENT = function() end }
LibEvent:Embed(target)
@@ -366,43 +366,11 @@ describe("LibEvent", function()
LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT")
LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT")
- assert.are.equal(3, LibEvent.embeds[target]._stats.TEST_EVENT)
- end)
-
- it("tracks stats independently per event", function()
- local target = {
- EVENT_A = function() end,
- EVENT_B = function() end,
- }
- LibEvent:Embed(target)
-
- target:RegisterEvent("EVENT_A", target.EVENT_A)
- target:RegisterEvent("EVENT_B", target.EVENT_B)
- LibEvent.embeds[target].frame.onEvent(nil, "EVENT_A")
- LibEvent.embeds[target].frame.onEvent(nil, "EVENT_A")
- LibEvent.embeds[target].frame.onEvent(nil, "EVENT_B")
-
- assert.are.equal(2, LibEvent.embeds[target]._stats.EVENT_A)
- assert.are.equal(1, LibEvent.embeds[target]._stats.EVENT_B)
- end)
-
- it("tracks stats independently per target", function()
- local first = { TEST_EVENT = function() end }
- local second = { TEST_EVENT = function() end }
- LibEvent:Embed(first)
- LibEvent:Embed(second)
-
- first:RegisterEvent("TEST_EVENT", first.TEST_EVENT)
- second:RegisterEvent("TEST_EVENT", second.TEST_EVENT)
- LibEvent.embeds[first].frame.onEvent(nil, "TEST_EVENT")
- LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT")
- LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT")
-
- assert.are.equal(1, LibEvent.embeds[first]._stats.TEST_EVENT)
- assert.are.equal(2, LibEvent.embeds[second]._stats.TEST_EVENT)
+ assert.is_nil(LibEvent.embeds[target]._stats)
+ assert.same({}, target:GetEventStats())
end)
- it("GetEventStats returns the target's _stats table", function()
+ it("GetEventStats returns an empty table when metrics debug is disabled", function()
local target = { TEST_EVENT = function() end }
LibEvent:Embed(target)
@@ -410,11 +378,11 @@ describe("LibEvent", function()
LibEvent.embeds[target].frame.onEvent(nil, "TEST_EVENT")
local stats = target:GetEventStats()
- assert.are.equal(LibEvent.embeds[target]._stats, stats)
- assert.are.equal(1, stats.TEST_EVENT)
+ assert.same({}, stats)
+ assert.is_nil(stats.TEST_EVENT)
end)
- it("ResetEventStats clears all counters for the target", function()
+ it("ResetEventStats is a no-op when metrics debug is disabled", function()
local target = {
EVENT_A = function() end,
EVENT_B = function() end,
@@ -431,23 +399,6 @@ describe("LibEvent", function()
assert.same({}, target:GetEventStats())
end)
- it("ResetEventStats does not affect other targets", function()
- local first = { TEST_EVENT = function() end }
- local second = { TEST_EVENT = function() end }
- LibEvent:Embed(first)
- LibEvent:Embed(second)
-
- first:RegisterEvent("TEST_EVENT", first.TEST_EVENT)
- second:RegisterEvent("TEST_EVENT", second.TEST_EVENT)
- LibEvent.embeds[first].frame.onEvent(nil, "TEST_EVENT")
- LibEvent.embeds[second].frame.onEvent(nil, "TEST_EVENT")
-
- first:ResetEventStats()
-
- assert.same({}, first:GetEventStats())
- assert.are.equal(1, second:GetEventStats().TEST_EVENT)
- end)
-
it("does not increment stats when no callbacks are registered for the event", function()
local target = {}
LibEvent:Embed(target)
diff --git a/Locales/en.lua b/Locales/en.lua
index 693db120..f591e7d9 100644
--- a/Locales/en.lua
+++ b/Locales/en.lua
@@ -285,20 +285,11 @@ L["CURSEFORGE"] = "CurseForge"
L["GITHUB"] = "GitHub"
L["WHATS_NEW_TITLE_FORMAT"] = "What's new in %s"
L["WHATS_NEW_BODY"] =
- "### Edit Mode Support\n"
- .. "You can now reposition ECM using WoW's built-in Edit Mode. Three layout modes are available:\n"
- .. "- Attached - Default, anchored to the cooldown manager as normal\n"
- .. "- Detached - Moveable stack of bars that move as a unit\n"
- .. "- Free - Place anywhere on screen\n\n"
- .. "Each module can be configured independently, so you can keep power and resource bars attached while moving aura bars somewhere else.\n\n"
- .. "### New Layout Settings Page\n"
- .. "A dedicated Layout section in the addon settings lets you control positioning mode and related options.\n\n"
- .. "### Demon Hunter Improvements\n"
- .. "- Tick marks now show progress toward Metamorphosis and Collapsing Star\n"
- .. "- The resource bar turns white when Metamorphosis or Collapsing Star can be cast\n\n"
- .. "### Settings UI Refresh\n"
- .. "- Options pages have been reorganized and cleaned up for easier navigation\n"
- .. "- Improvements to the profile management interface"
+ "### Add Icons for any spell or item\n\n"
+ .. "- Custom spell and item icons can now be added to the cooldown manager. Simply find the item or spell ID and add it in settings.\n"
+ .. "- Spell charges and item counts can now be shown.\n\n"
+ .. "### External Defensives\n\n"
+ .. "External defensives cast on you can now be displayed as a bar. The list of spells is controlled by Blizzard."
L["CLOSE"] = "Close"
L["OPEN_SETTINGS"] = "Open settings"
@@ -363,13 +354,16 @@ L["EXPORT_FAILED"] = "Export failed: %s"
-- Chat Commands
--------------------------------------------------------------------------------
-L["CMD_HELP_CLEARSEEN"] = "/ecm clearseen - clear the flag indicating the whats new popup was seen"
+L["CMD_HELP_CLEARSEEN"] = "/ecm clearseen - clear the flag indicating the whats new popup was seen and reload the UI"
L["CMD_HELP_DEBUG"] = "/ecm debug [on | off | toggle] - toggle debug mode (logs detailed info to the chat frame)"
-L["CMD_HELP_EVENTS"] = "/ecm events [reset] - show or reset event fire counts"
+L["CMD_HELP_EVENTS"] = "/ecm events - show event fire counts"
+L["CMD_HELP_EVENTS_RESET"] = "/ecm events reset - reset event fire counts"
L["CMD_HELP_HELP"] = "/ecm help - show this message"
L["CMD_HELP_MIGRATION"] = "/ecm migration - show migration info and commands"
-L["CMD_HELP_OPTIONS"] = "/ecm options|config|settings|o - open the options menu"
-L["CMD_HELP_REFRESH"] = "/ecm rl|reload|refresh - refresh and reapply layout for all modules"
+L["CMD_HELP_MIGRATION_LOG"] = "/ecm migration log - show the settings migration log"
+L["CMD_HELP_MIGRATION_ROLLBACK"] = "/ecm migration rollback - rollback to a settings version (-1 deletes current only)"
+L["CMD_HELP_OPTIONS"] = "/ecm options | config | settings | o - open the options menu"
+L["CMD_HELP_REFRESH"] = "/ecm rl | reload | refresh - refresh and reapply layout for all modules"
L["REFRESHING_ALL_MODULES"] = "Refreshing all modules."
L["OPTIONS_BLOCKED_COMBAT"] = "Options cannot be opened during combat. It will open when combat ends."
L["MIGRATION_LOG_TITLE"] = "Migration Log"
@@ -384,7 +378,7 @@ L["MODULE_NOT_FOUND"] = "Module not found:"
L["EVENTS_HEADER"] = "Event fire counts:"
L["EVENTS_NONE"] = "No events recorded."
L["EVENTS_RESET"] = "Event stats reset."
-L["SEEN_CLEARED"] = "What's New seen flag cleared. Reload or relog to show the popup again."
+L["SEEN_CLEARED"] = "What's New seen flag cleared. Reloading UI."
--------------------------------------------------------------------------------
-- Import / Export Dialogs
diff --git a/README.md b/README.md
index e43776a1..ab5348f2 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,15 @@ Styles the default aura bars to match power and resource bars.
Customise their colours **on a per-spell basis** to quickly identify their remaining duration mid-combat.
+#### Add Icons for Any Spell or Item
+
+Add an icon to either the main or utility bars for **any** item or spell ID: healthstones, potions, jumper cables, racials — anything.
+
+
+#### External Defensives
+
+Display external defensives cast on you as a bar inline or anywhere on the screen.
+
#### Auto-hide and Fade
Reduce screen clutter based on context:
@@ -30,9 +39,6 @@ Reduce screen clutter based on context:
- Optionally stay visible in instances: raids, M+, and PvP
- Optionally stay visible when you have an attackable target
-#### Add Icons for Any Spell or Item
-
-Add an icon to either the main or utility bars for **any** item or spell ID: healthstones, potions, jumper cables, racials — anything.
#### Automatic or Free Positioning
diff --git a/Tests/ChatCommand_spec.lua b/Tests/ChatCommand_spec.lua
index d5e43891..ba5a1dc4 100644
--- a/Tests/ChatCommand_spec.lua
+++ b/Tests/ChatCommand_spec.lua
@@ -16,6 +16,7 @@ describe("ChatCommand migration", function()
local shownMigrationLog
local openOptionsCalls
local scheduleLayoutCalls
+ local reloadUICalls
local ns
setup(function()
@@ -71,6 +72,7 @@ describe("ChatCommand migration", function()
shownMigrationLog = nil
openOptionsCalls = 0
scheduleLayoutCalls = {}
+ reloadUICalls = 0
_G.strtrim = function(s)
return tostring(s):match("^%s*(.-)%s*$")
@@ -115,7 +117,9 @@ describe("ChatCommand migration", function()
_G.StaticPopup_Show = function() end
_G.YES = "Yes"
_G.NO = "No"
- _G.ReloadUI = function() end
+ _G.ReloadUI = function()
+ reloadUICalls = reloadUICalls + 1
+ end
_G.GetTime = function()
return 0
end
@@ -440,6 +444,30 @@ describe("ChatCommand migration", function()
end
end)
+ it("/ecm help includes every slash command and spaced alias separators", function()
+ mod:ChatCommand("help")
+
+ local helpText = table.concat(printedMessages, "\n")
+ local expectedCommands = {
+ "/ecm clearseen",
+ "/ecm debug [on | off | toggle]",
+ "/ecm events -",
+ "/ecm events reset",
+ "/ecm help",
+ "/ecm migration -",
+ "/ecm migration log",
+ "/ecm migration rollback ",
+ "/ecm options | config | settings | o",
+ "/ecm rl | reload | refresh",
+ }
+
+ for _, command in ipairs(expectedCommands) do
+ assert.is_not_nil(string.find(helpText, command, 1, true), "Expected help to include " .. command)
+ end
+ assert.is_nil(string.find(helpText, "options|", 1, true))
+ assert.is_nil(string.find(helpText, "rl|", 1, true))
+ end)
+
it("/ecm settings defers opening options during combat", function()
_G.InCombatLockdown = function()
return true
@@ -471,16 +499,14 @@ describe("ChatCommand migration", function()
assert.are.equal("Refreshing all modules.", printedMessages[1])
end)
- it("/ecm clearseen clears the persisted What's New version and prints reload guidance", function()
+ it("/ecm clearseen clears the persisted What's New version and reloads the UI", function()
fakeAddon.db.profile.global.releasePopupSeenVersion = "v1.2.3"
mod:ChatCommand("clearseen")
assert.is_nil(fakeAddon.db.profile.global.releasePopupSeenVersion)
- assert.are.equal(
- "What's New seen flag cleared. Reload or relog to show the popup again.",
- printedMessages[1]
- )
+ assert.are.equal("What's New seen flag cleared. Reloading UI.", printedMessages[1])
+ assert.are.equal(1, reloadUICalls)
end)
describe("events command", function()
From 26ba521d52df3410a494ae309ef39c8f08a97b8c Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 1 May 2026 17:23:42 +1000
Subject: [PATCH 49/53] Add agent environment setup script
---
.github/workflows/copilot-setup-steps.yml | 23 +++++
scripts/setup-unit-test-env.sh | 100 ++++++++++++++++++++++
2 files changed, 123 insertions(+)
create mode 100644 .github/workflows/copilot-setup-steps.yml
create mode 100644 scripts/setup-unit-test-env.sh
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
new file mode 100644
index 00000000..34491b7d
--- /dev/null
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -0,0 +1,23 @@
+name: Copilot Setup Steps
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+ pull_request:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+
+jobs:
+ copilot-setup-steps:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+
+ - name: Run Codex environment setup
+ run: bash scripts/setup-unit-test-env.sh
diff --git a/scripts/setup-unit-test-env.sh b/scripts/setup-unit-test-env.sh
new file mode 100644
index 00000000..f317f119
--- /dev/null
+++ b/scripts/setup-unit-test-env.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+# Enhanced Cooldown Manager addon for World of Warcraft
+# Author: Argium
+# Licensed under the GNU General Public License v3.0
+
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$repo_root"
+
+if [[ "${EUID}" -eq 0 ]]; then
+ sudo_cmd=()
+elif command -v sudo >/dev/null 2>&1; then
+ sudo_cmd=(sudo)
+else
+ echo "Root privileges or sudo are required to install apt packages." >&2
+ exit 1
+fi
+
+export DEBIAN_FRONTEND=noninteractive
+
+"${sudo_cmd[@]}" apt-get update
+"${sudo_cmd[@]}" apt-get install -y --no-install-recommends \
+ build-essential \
+ ca-certificates \
+ curl \
+ git \
+ liblua5.1-0-dev \
+ lua5.1 \
+ luarocks
+
+"${sudo_cmd[@]}" luarocks --lua-version=5.1 install moonscript 0.6.0-1
+"${sudo_cmd[@]}" luarocks --lua-version=5.1 install busted 2.3.0-1
+"${sudo_cmd[@]}" luarocks --lua-version=5.1 install luacov 0.17.0-1
+"${sudo_cmd[@]}" luarocks --lua-version=5.1 install luacheck 1.2.0-1
+"${sudo_cmd[@]}" luarocks --lua-version=5.1 install luacov-html 1.0.0-1
+
+# External libraries are intentionally not downloaded by default.
+# Uncomment this block if the Codex setup phase should vendor .pkgmeta externals before sandboxing.
+#
+# "${sudo_cmd[@]}" apt-get install -y --no-install-recommends subversion unzip
+#
+# fetch_root="$(mktemp -d)"
+# trap 'rm -rf "$fetch_root"' EXIT
+#
+# fetch_svn_external() {
+# local url="$1"
+# local target="$2"
+#
+# rm -rf "$target"
+# mkdir -p "$(dirname "$target")"
+# svn export --force "$url" "$target"
+# }
+#
+# fetch_github_tag() {
+# local owner_repo="$1"
+# local tag="$2"
+# local target="$3"
+# local repo="${owner_repo##*/}"
+# local zip_file="$fetch_root/${repo}.zip"
+# local extract_dir="$fetch_root/${repo}"
+# local entries
+#
+# rm -rf "$target" "$extract_dir"
+# mkdir -p "$(dirname "$target")" "$extract_dir"
+# curl -fsSL "https://codeload.github.com/${owner_repo}/zip/refs/tags/${tag}" -o "$zip_file"
+# unzip -q "$zip_file" -d "$extract_dir"
+#
+# entries=("$extract_dir"/*)
+# if [[ "${#entries[@]}" -ne 1 || ! -d "${entries[0]}" ]]; then
+# echo "Unexpected archive layout for ${owner_repo}@${tag}" >&2
+# exit 1
+# fi
+#
+# mv "${entries[0]}" "$target"
+# }
+#
+# fetch_svn_external https://repos.wowace.com/wow/libstub/trunk Libs/LibStub
+# fetch_svn_external https://repos.wowace.com/wow/callbackhandler/trunk/CallbackHandler-1.0 Libs/CallbackHandler-1.0
+# fetch_svn_external https://repos.wowace.com/wow/ace3/trunk/AceAddon-3.0 Libs/AceAddon-3.0
+# fetch_svn_external https://repos.wowace.com/wow/ace3/trunk/AceDB-3.0 Libs/AceDB-3.0
+# fetch_svn_external https://repos.wowace.com/wow/libsharedmedia-3-0/trunk/LibSharedMedia-3.0 Libs/LibSharedMedia-3.0
+# fetch_github_tag SafeteeWoW/LibDeflate 1.0.2-release Libs/LibDeflate
+# fetch_github_tag p3lim-wow/LibEditMode 15 Libs/LibEditMode
+# fetch_github_tag rossnichols/LibSerialize v1.2.1 Libs/LibSerialize
+
+cat <<'EOF'
+
+Setup complete.
+
+After sandboxing, run:
+
+ busted Tests
+ busted --run libsettingsbuilder
+ busted --run libconsole
+ busted --run libevent
+ busted --run liblsmsettingswidgets
+ luacheck . -q
+
+EOF
From d93404fc60b8d33cdff789becff6622349a59bc7 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Fri, 1 May 2026 17:39:24 +1000
Subject: [PATCH 50/53] update pipelines
---
.github/workflows/release.yml | 14 ++++----------
.github/workflows/unit-tests.yml | 14 ++++----------
2 files changed, 8 insertions(+), 20 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 23032903..2382c2e2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -27,20 +27,14 @@ jobs:
with:
fetch-depth: 0
- - name: Install Lua 5.1 and LuaRocks
- run: |
- sudo apt-get update
- sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks
+ - name: Set up test environment
+ run: bash scripts/setup-unit-test-env.sh
- - name: Install test dependencies
- run: |
- luarocks --lua-version=5.1 install --local moonscript
- luarocks --lua-version=5.1 install --local busted
- luarocks --lua-version=5.1 install --local luacov
+ - name: Run Luacheck
+ run: luacheck . -q
- name: Run Busted tests with LuaCov (JUnit output)
run: |
- eval "$(luarocks --lua-version=5.1 path --local)"
mkdir -p test-results
rm -f luacov.stats.out luacov.report.out luacov.report.html
busted -r coverage Tests --output=junit > test-results/busted-junit.xml
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index bbd9e703..5a36c0f0 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -24,20 +24,14 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- - name: Install Lua 5.1 and LuaRocks
- run: |
- sudo apt-get update
- sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks
+ - name: Set up test environment
+ run: bash scripts/setup-unit-test-env.sh
- - name: Install test dependencies
- run: |
- luarocks --lua-version=5.1 install --local moonscript
- luarocks --lua-version=5.1 install --local busted
- luarocks --lua-version=5.1 install --local luacov
+ - name: Run Luacheck
+ run: luacheck . -q
- name: Run Busted tests with LuaCov (JUnit output)
run: |
- eval "$(luarocks --lua-version=5.1 path --local)"
mkdir -p test-results
rm -f luacov.stats.out luacov.report.out luacov.report.html
busted -r coverage Tests --output=junit > test-results/busted-junit.xml
From e72b18ee9eb32605f63168ff649133fa6e0ac610 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 2 May 2026 09:33:54 +1000
Subject: [PATCH 51/53] Add pre-release checklist skill. Update metrics docs to
note that they're disabled by default. Add init serena to instructions.
---
.agents/skills/pre-release-checklist/SKILL.md | 45 +++++++++++++++++++
.../pre-release-checklist/agents/openai.yaml | 4 ++
AGENTS.md | 2 +
Libs/LibEvent/LibEvent.lua | 7 ++-
Libs/LibEvent/README.md | 6 +--
5 files changed, 57 insertions(+), 7 deletions(-)
create mode 100644 .agents/skills/pre-release-checklist/SKILL.md
create mode 100644 .agents/skills/pre-release-checklist/agents/openai.yaml
diff --git a/.agents/skills/pre-release-checklist/SKILL.md b/.agents/skills/pre-release-checklist/SKILL.md
new file mode 100644
index 00000000..726a0708
--- /dev/null
+++ b/.agents/skills/pre-release-checklist/SKILL.md
@@ -0,0 +1,45 @@
+---
+name: pre-release-checklist
+description: Run the EnhancedCooldownManager pre-release checklist before publishing or tagging a release. Use when Codex is asked to verify release readiness, prepare a release, review final release risk, or specifically check options schema migration coverage.
+---
+
+# Pre-Release Checklist
+
+## Overview
+
+Verify release readiness for EnhancedCooldownManager with emphasis on schema migrations and test coverage. Treat missing verification as a release blocker and report exact gaps.
+
+## Workflow
+
+1. Inspect the pending release changes with `git status --short` and focused diffs.
+2. Determine whether the options schema version increased.
+3. If the options schema version increased, verify that the schema changes are incorporated into `Migration.lua`.
+4. Verify migration test coverage for every schema change.
+5. Verify black-box tests cover old saved-variable data migrating to the expected current shape.
+6. Ensure `AGENTS.md`, `ARCHITECTURE.md`, and documentation are accurate and consistent with the product code.
+7. Ask the user whether `RELEASE_POPUP_VERSION` in `Constants.lua` needs to be updated so that a release prompt is displayed again.
+8. If `RELEASE_POPUP_VERSION` needs to be updated, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release.
+9. Run the repo validation required by `AGENTS.md` for touched surfaces, or state exactly why validation could not be run.
+
+## Options Schema Checks
+
+When the options schema version increased:
+
+- Confirm `Migration.lua` includes migration logic for the new schema changes.
+- Confirm tests cover the migration behavior directly.
+- Confirm black-box tests start from representative old saved-variable data and assert the migrated current shape.
+- Do not mark the release ready if migration logic or either test class is missing.
+
+## Release Prompt Checks
+
+- Ask the user whether `RELEASE_POPUP_VERSION` in `Constants.lua` needs to be updated so that a release prompt is displayed again.
+- If the answer is yes, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release.
+- Treat an outdated `WHATS_NEW_BODY` as a release blocker when the release prompt version is updated.
+
+## Reporting
+
+Report release readiness as:
+
+- `Ready`: all applicable checks and validation passed.
+- `Blocked`: list each blocker with file paths and missing work.
+- `Not verified`: list checks that could not be completed and why.
diff --git a/.agents/skills/pre-release-checklist/agents/openai.yaml b/.agents/skills/pre-release-checklist/agents/openai.yaml
new file mode 100644
index 00000000..1a2fa699
--- /dev/null
+++ b/.agents/skills/pre-release-checklist/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "Pre-Release Checklist"
+ short_description: "Run release-readiness checks"
+ default_prompt: "Use $pre-release-checklist to verify release readiness before publishing."
diff --git a/AGENTS.md b/AGENTS.md
index 94ede618..e09897c6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,5 @@
+IMPORTANT: Run initialize serena tool, if it's available.
+
# Documentation Map
Authoritative source for repo-wide agent rules. Topic-specific docs own their own surface — do not duplicate their content here.
diff --git a/Libs/LibEvent/LibEvent.lua b/Libs/LibEvent/LibEvent.lua
index 3fa2a934..0c804c35 100644
--- a/Libs/LibEvent/LibEvent.lua
+++ b/Libs/LibEvent/LibEvent.lua
@@ -92,13 +92,13 @@ function LibEvent:UnregisterAllEvents()
end
end
----Gets the event invocation stats for this embedded target.
----@return table A table mapping event names to their fire counts.
+---Gets event invocation stats when metrics are enabled.
+---@return table A table mapping event names to their fire counts, or an empty table when metrics are disabled.
function LibEvent:GetEventStats()
return getInstance(self)._stats or EMPTY_STATS
end
----Resets the event invocation stats for this embedded target.
+---Resets event invocation stats when metrics are enabled.
function LibEvent:ResetEventStats()
local stats = getInstance(self)._stats
if stats then wipe(stats) end
@@ -115,7 +115,6 @@ local function createInstance(target)
end
instance._events = instance._events or {}
- instance._stats = instance._stats or {}
instance.frame = instance.frame or CreateFrame("Frame")
diff --git a/Libs/LibEvent/README.md b/Libs/LibEvent/README.md
index ef71e1e9..07185eb2 100644
--- a/Libs/LibEvent/README.md
+++ b/Libs/LibEvent/README.md
@@ -9,7 +9,7 @@ Distributed via [LibStub](https://www.wowace.com/projects/libstub).
- Embed into any table to give it event registration capabilities.
- Zero-allocation dispatch loop — no snapshot copies per fire.
- Idempotent embedding — safe to re-embed on library upgrades.
-- Per-event callback stats via `GetEventStats` / `ResetEventStats`.
+- Metrics hooks via `GetEventStats` / `ResetEventStats`; metrics are disabled by default, so these APIs return no counts in normal runtime.
## Quick start
@@ -33,8 +33,8 @@ end)
| `UnregisterEvent(event, callback)` | Remove a specific callback. |
| `UnregisterAllEvents()` | Remove all callbacks and unregister the hidden frame. |
| `Fire(event, ...)` | Manually fire an event on the target. |
-| `GetEventStats()` | Returns a table of event → fire-count. |
-| `ResetEventStats()` | Clears all accumulated stats. |
+| `GetEventStats()` | Returns event fire counts when metrics are enabled, otherwise an empty table. |
+| `ResetEventStats()` | Clears accumulated stats when metrics are enabled. |
## Testing
From 78254e9562ca838cbc2c4467d41b5264583a0fed Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 2 May 2026 19:23:28 +1000
Subject: [PATCH 52/53] update release prefs
---
.agents/skills/pre-release-checklist/SKILL.md | 26 +-
.github/workflows/release.yml | 279 +++++++++----
.github/workflows/unit-tests.yml | 1 +
.serena/memories/user/debugging-preference.md | 1 +
EnhancedCooldownManager.toc | 2 +
Modules/ExternalBars.lua | 2 +-
Runtime.lua | 5 +-
Tests/ECM_Runtime_spec.lua | 7 +-
Tests/Modules/ExternalBars_spec.lua | 20 +-
scripts/publish-tag-toc.ps1 | 188 ---------
scripts/start-release.ps1 | 391 ++++++++++++++++++
11 files changed, 645 insertions(+), 277 deletions(-)
create mode 100644 .serena/memories/user/debugging-preference.md
delete mode 100644 scripts/publish-tag-toc.ps1
create mode 100644 scripts/start-release.ps1
diff --git a/.agents/skills/pre-release-checklist/SKILL.md b/.agents/skills/pre-release-checklist/SKILL.md
index 726a0708..d4be77b3 100644
--- a/.agents/skills/pre-release-checklist/SKILL.md
+++ b/.agents/skills/pre-release-checklist/SKILL.md
@@ -1,13 +1,13 @@
---
name: pre-release-checklist
-description: Run the EnhancedCooldownManager pre-release checklist before publishing or tagging a release. Use when Codex is asked to verify release readiness, prepare a release, review final release risk, or specifically check options schema migration coverage.
+description: Run the EnhancedCooldownManager pre-release checklist before dispatching a release workflow. Use when Codex is asked to verify release readiness, prepare a release, review final release risk, or specifically check options schema migration coverage.
---
# Pre-Release Checklist
## Overview
-Verify release readiness for EnhancedCooldownManager with emphasis on schema migrations and test coverage. Treat missing verification as a release blocker and report exact gaps.
+Verify release readiness for EnhancedCooldownManager with emphasis on schema migrations, test coverage, release preconditions, and manual workflow steps. Treat missing verification as a release blocker and report exact gaps.
## Workflow
@@ -19,7 +19,18 @@ Verify release readiness for EnhancedCooldownManager with emphasis on schema mig
6. Ensure `AGENTS.md`, `ARCHITECTURE.md`, and documentation are accurate and consistent with the product code.
7. Ask the user whether `RELEASE_POPUP_VERSION` in `Constants.lua` needs to be updated so that a release prompt is displayed again.
8. If `RELEASE_POPUP_VERSION` needs to be updated, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release.
-9. Run the repo validation required by `AGENTS.md` for touched surfaces, or state exactly why validation could not be run.
+9. Verify the release preconditions below.
+10. Run the repo validation required by `AGENTS.md` for touched surfaces, or state exactly why validation could not be run.
+
+## Release Preconditions
+
+- Confirm `EnhancedCooldownManager.toc` has the final `## Version:` value and that it starts with `v`.
+- Treat versions containing `-` as prereleases. Stable versions must be released from `main`; prereleases may be released from any pushed branch.
+- Confirm the TOC version change is committed before dispatch; unrelated local edits do not block the helper.
+- Confirm release notes are prepared for `scripts/start-release.ps1 -Message` or `-MessagePath`.
+- Confirm `gh auth status` succeeds when the helper script will be used.
+- Confirm no conflicting remote tag or GitHub Release already exists for the TOC version; a remote tag that already points at the dispatch commit is allowed only for a retry.
+- Treat an existing remote tag that points at another commit, existing GitHub Release, missing release notes, failed validation, or wrong release branch as a release blocker.
## Options Schema Checks
@@ -36,6 +47,15 @@ When the options schema version increased:
- If the answer is yes, confirm `WHATS_NEW_BODY` in `Locales/en.lua` has been updated in this release.
- Treat an outdated `WHATS_NEW_BODY` as a release blocker when the release prompt version is updated.
+## Manual Release Steps
+
+1. Run this checklist and resolve all blockers.
+2. Update `RELEASE_POPUP_VERSION` and `WHATS_NEW_BODY` when a release prompt should be shown.
+3. Dispatch the release with `scripts/start-release.ps1 -Message "..."`; use `-MessagePath` for multiline release notes.
+4. Monitor the `release.yml` workflow until validation completes.
+5. Approve the `release` environment after validation succeeds.
+6. Verify the workflow created the GitHub tag, GitHub Release, and release artifacts.
+
## Reporting
Report release readiness as:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2382c2e2..0f8a4ea3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,24 +2,35 @@ name: Package Release
on:
workflow_dispatch:
- push:
- tags:
- - "v*"
+ inputs:
+ release_notes:
+ description: Release notes for the packager changelog and GitHub Release.
+ required: true
+ type: string
concurrency:
- group: release-${{ github.ref }}
+ group: release
cancel-in-progress: false
env:
ADDON_NAME: EnhancedCooldownManager
jobs:
- package:
+ validate:
+ name: Validate
+ uses: ./.github/workflows/unit-tests.yml
+ permissions:
+ contents: read
+ checks: write
+ pull-requests: write
+
+ publish:
+ name: Publish
+ needs: validate
runs-on: ubuntu-latest
environment: release
permissions:
contents: write
- checks: write
steps:
- name: Checkout repository
@@ -27,104 +38,216 @@ jobs:
with:
fetch-depth: 0
- - name: Set up test environment
- run: bash scripts/setup-unit-test-env.sh
+ - name: Validate release inputs
+ id: release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ RELEASE_NOTES: ${{ inputs.release_notes }}
+ REF_NAME: ${{ github.ref_name }}
+ REF_TYPE: ${{ github.ref_type }}
+ run: |
+ set -euo pipefail
+
+ if [ "$REF_TYPE" != "branch" ]; then
+ echo "::error::Release workflow must be dispatched from a branch, not '$REF_TYPE'."
+ exit 1
+ fi
+
+ if [ -z "$(printf '%s' "$RELEASE_NOTES" | tr -d '[:space:]')" ]; then
+ echo "::error::Release notes are required."
+ exit 1
+ fi
+
+ VERSION=$(grep -oP '## Version: \K.*' "${ADDON_NAME}.toc" | tr -d '[:space:]')
+ if [ -z "$VERSION" ]; then
+ echo "::error::Could not read a non-empty Version field from ${ADDON_NAME}.toc."
+ exit 1
+ fi
+
+ if [[ "$VERSION" != v* ]]; then
+ echo "::error::TOC version '$VERSION' must start with 'v'."
+ exit 1
+ fi
+
+ IS_PRERELEASE=false
+ if [[ "$VERSION" == *-* ]]; then
+ IS_PRERELEASE=true
+ VERSION_LOWER=${VERSION,,}
+ if [[ "$VERSION_LOWER" != *alpha* && "$VERSION_LOWER" != *beta* ]]; then
+ echo "::error::Prerelease TOC version '$VERSION' must include 'alpha' or 'beta' so external uploads are not marked stable."
+ exit 1
+ fi
+ elif [ "$REF_NAME" != "main" ]; then
+ echo "::error::Stable releases must be dispatched from main. '$VERSION' was dispatched from '$REF_NAME'."
+ exit 1
+ fi
+
+ REMOTE_TAG_LINES=$(git ls-remote --tags origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}")
+ if [ -n "$(printf '%s' "$REMOTE_TAG_LINES" | tr -d '[:space:]')" ]; then
+ REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION^{}" '$2 == ref { print $1; exit }')
+ if [ -z "$REMOTE_TARGET" ]; then
+ REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION" '$2 == ref { print $1; exit }')
+ fi
+
+ if [ "$REMOTE_TARGET" != "$GITHUB_SHA" ]; then
+ echo "::error::Remote tag '$VERSION' already exists but points to '$REMOTE_TARGET', not '$GITHUB_SHA'."
+ exit 1
+ fi
+
+ echo "Remote tag '$VERSION' already points at this commit; continuing retry."
+ fi
+
+ set +e
+ RELEASE_QUERY=$(gh release view "$VERSION" --json tagName 2>&1)
+ RELEASE_STATUS=$?
+ set -e
- - name: Run Luacheck
- run: luacheck . -q
+ if [ "$RELEASE_STATUS" -eq 0 ]; then
+ echo "::error::GitHub Release '$VERSION' already exists."
+ exit 1
+ fi
+
+ if ! printf '%s' "$RELEASE_QUERY" | grep -qi 'not found'; then
+ echo "::error::Failed checking GitHub Release '$VERSION': $RELEASE_QUERY"
+ exit 1
+ fi
- - name: Run Busted tests with LuaCov (JUnit output)
+ {
+ echo "version=$VERSION"
+ echo "is_prerelease=$IS_PRERELEASE"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Release version: $VERSION"
+ echo "Release ref: $REF_NAME"
+ echo "Prerelease: $IS_PRERELEASE"
+
+ - name: Prepare release notes
+ env:
+ RELEASE_NOTES: ${{ inputs.release_notes }}
run: |
- mkdir -p test-results
- rm -f luacov.stats.out luacov.report.out luacov.report.html
- busted -r coverage Tests --output=junit > test-results/busted-junit.xml
- busted -r coverage --run libsettingsbuilder --output=junit > test-results/lsb-junit.xml
- busted -r coverage --run libconsole --output=junit > test-results/libconsole-junit.xml
- busted -r coverage --run libevent --output=junit > test-results/libevent-junit.xml
- busted -r coverage --run liblsmsettingswidgets --output=junit > test-results/liblsm-junit.xml
- luacov
- luacov --config .luacov-html
- mv luacov.report.out test-results/luacov.report.out
- mv luacov.report.html test-results/luacov.report.html
-
- - name: Show coverage report
- run: cat test-results/luacov.report.out
-
- - name: Upload coverage report
- uses: actions/upload-artifact@v6
- with:
- name: luacov-report
- path: test-results/luacov.report.out
+ set -euo pipefail
- - name: Upload HTML coverage report
- uses: actions/upload-artifact@v6
- with:
- name: luacov-html-report
- path: test-results/luacov.report.html
+ cp .pkgmeta .github/release.pkgmeta
+ printf '%s\n' "$RELEASE_NOTES" > .github/release-notes.md
+ printf '\n%s\n' 'manual-changelog:' >> .github/release.pkgmeta
+ printf '%s\n' \
+ ' filename: .github/release-notes.md' \
+ ' markup-type: markdown' \
+ >> .github/release.pkgmeta
- - name: Publish Test Results
- uses: EnricoMi/publish-unit-test-result-action@v2.23.0
- with:
- action_fail: true
- action_fail_on_inconclusive: true
- comment_mode: off
- files: |
- test-results/busted-junit.xml
- test-results/lsb-junit.xml
- test-results/libconsole-junit.xml
- test-results/libevent-junit.xml
- test-results/liblsm-junit.xml
-
- - name: Validate tag matches TOC version
+ - name: Create local release tag
+ env:
+ RELEASE_NOTES: ${{ inputs.release_notes }}
+ VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
- VERSION="${{ github.ref_name }}"
- TOC_VERSION=$(grep -oP '## Version: \K.*' "${ADDON_NAME}.toc" | tr -d '[:space:]')
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- echo "Tag version: $VERSION"
- echo "TOC version: $TOC_VERSION"
+ if git show-ref --verify --quiet "refs/tags/$VERSION"; then
+ TAG_TARGET=$(git rev-parse "refs/tags/$VERSION^{}")
+ if [ "$TAG_TARGET" != "$GITHUB_SHA" ]; then
+ echo "::error::Local tag '$VERSION' points to '$TAG_TARGET', not '$GITHUB_SHA'."
+ exit 1
+ fi
+ else
+ git tag -a "$VERSION" -m "$RELEASE_NOTES" "$GITHUB_SHA"
+ fi
- if [ "$VERSION" != "$TOC_VERSION" ]; then
- echo "::error::Version mismatch! Tag is '$VERSION' but TOC file has '$TOC_VERSION'. Please update the TOC file version before releasing."
+ TAGS_AT_HEAD=$(git tag --points-at "$GITHUB_SHA")
+ if [ "$TAGS_AT_HEAD" != "$VERSION" ]; then
+ echo "::error::Expected '$VERSION' to be the only tag at HEAD; found:"
+ printf '%s\n' "$TAGS_AT_HEAD"
exit 1
fi
- - name: Prepare packager release notes
+ - name: Package release artifact
+ uses: BigWigsMods/packager@v2
+ with:
+ args: -m .github/release.pkgmeta -d
+ env:
+ CF_API_KEY: ${{ secrets.CF_TOKEN }}
+ WOWI_API_TOKEN: ${{ secrets.WOWINTF_TOKEN }}
+ WAGO_API_TOKEN: ${{ secrets.WAGO_API_TOKEN }}
+
+ - name: Verify packaged artifact
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
+ shopt -s nullglob
- TAG_NAME="${{ github.ref_name }}"
- TAG_MESSAGE=$(git for-each-ref --format='%(contents)' "refs/tags/$TAG_NAME")
-
- if [ -f .pkgmeta ]; then
- cp .pkgmeta .github/release.pkgmeta
- else
- : > .github/release.pkgmeta
+ packages=(.release/*.zip)
+ if [ "${#packages[@]}" -eq 0 ]; then
+ echo "::error::Packager did not create any release zip files."
+ exit 1
fi
- if [ -n "$(printf '%s' "$TAG_MESSAGE" | tr -d '[:space:]')" ]; then
- printf '%s\n' "$TAG_MESSAGE" > .github/release-notes.md
- printf '\n%s\n' 'manual-changelog:' >> .github/release.pkgmeta
- printf '%s\n' \
- ' filename: .github/release-notes.md' \
- ' markup-type: markdown' \
- >> .github/release.pkgmeta
- echo "Using annotated tag message for release notes."
+ matching=()
+ for package in "${packages[@]}"; do
+ name=$(basename "$package")
+ echo "Found package: $name"
+ if [[ "$name" == "${ADDON_NAME}-${VERSION}"*.zip ]]; then
+ matching+=("$package")
+ fi
+ done
+
+ if [ "${#matching[@]}" -eq 0 ]; then
+ echo "::error::No package zip matched expected version '$VERSION'."
+ exit 1
fi
- echo "PACKAGER_ARGS=-m .github/release.pkgmeta -p 1427906 -w 27051" >> "$GITHUB_ENV"
+ - name: Push release tag
+ env:
+ VERSION: ${{ steps.release.outputs.version }}
+ run: |
+ set -euo pipefail
- if [ -z "$(printf '%s' "$TAG_MESSAGE" | tr -d '[:space:]')" ]; then
- echo "No annotated tag message found; using generated changelog."
+ REMOTE_TAG_LINES=$(git ls-remote --tags origin "refs/tags/$VERSION" "refs/tags/$VERSION^{}")
+ if [ -n "$(printf '%s' "$REMOTE_TAG_LINES" | tr -d '[:space:]')" ]; then
+ REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION^{}" '$2 == ref { print $1; exit }')
+ if [ -z "$REMOTE_TARGET" ]; then
+ REMOTE_TARGET=$(printf '%s\n' "$REMOTE_TAG_LINES" | awk -v ref="refs/tags/$VERSION" '$2 == ref { print $1; exit }')
+ fi
+
+ if [ "$REMOTE_TARGET" != "$GITHUB_SHA" ]; then
+ echo "::error::Remote tag '$VERSION' already exists but points to '$REMOTE_TARGET', not '$GITHUB_SHA'."
+ exit 1
+ fi
+
+ echo "Remote tag '$VERSION' already points at this commit; not pushing."
+ else
+ git push origin "refs/tags/$VERSION"
fi
- - name: Package and release
+ - name: Upload external releases
uses: BigWigsMods/packager@v2
with:
- args: ${{ env.PACKAGER_ARGS }}
+ args: -m .github/release.pkgmeta
env:
CF_API_KEY: ${{ secrets.CF_TOKEN }}
WOWI_API_TOKEN: ${{ secrets.WOWINTF_TOKEN }}
WAGO_API_TOKEN: ${{ secrets.WAGO_API_TOKEN }}
- GITHUB_OAUTH: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload packaged artifacts
+ uses: actions/upload-artifact@v6
+ with:
+ name: release-packages-${{ steps.release.outputs.version }}
+ path: .release/*.zip
+ if-no-files-found: error
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.release.outputs.version }}
+ IS_PRERELEASE: ${{ steps.release.outputs.is_prerelease }}
+ run: |
+ set -euo pipefail
+
+ release_args=(release create "$VERSION" .release/*.zip --verify-tag --notes-file .github/release-notes.md)
+ if [ "$IS_PRERELEASE" = "true" ]; then
+ release_args+=(--prerelease --latest=false)
+ fi
+
+ gh "${release_args[@]}"
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 5a36c0f0..1098fb25 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -1,6 +1,7 @@
name: Unit Tests
on:
+ workflow_call:
workflow_dispatch:
pull_request:
push:
diff --git a/.serena/memories/user/debugging-preference.md b/.serena/memories/user/debugging-preference.md
new file mode 100644
index 00000000..4b1b4b04
--- /dev/null
+++ b/.serena/memories/user/debugging-preference.md
@@ -0,0 +1 @@
+User prefers being asked to take concrete debugging/reproduction steps over speculative fixes when evidence is incomplete. For runtime bugs without a stack trace or clear failing line, avoid guessing; propose targeted diagnostics, module isolation, and evidence collection before patching.
\ No newline at end of file
diff --git a/EnhancedCooldownManager.toc b/EnhancedCooldownManager.toc
index a3fb2bf7..ff4f475b 100644
--- a/EnhancedCooldownManager.toc
+++ b/EnhancedCooldownManager.toc
@@ -6,6 +6,8 @@
## SavedVariables: EnhancedCooldownManagerDB
## Category-enUS: User Interface
## IconTexture: Interface\AddOns\EnhancedCooldownManager\Media\icon
+## X-Curse-Project-ID: 1427906
+## X-WoWI-ID: 27051
## X-Wago-ID: qGZOwbNd
Libs\LibStub\LibStub.lua
diff --git a/Modules/ExternalBars.lua b/Modules/ExternalBars.lua
index d88092cb..2f515330 100644
--- a/Modules/ExternalBars.lua
+++ b/Modules/ExternalBars.lua
@@ -661,7 +661,7 @@ function ExternalBars:OnExternalAurasUpdated()
local auraDiagnostics = debugEnabled and {} or nil
local activeAuraCount = 0
- if type(auraInfo) == "table" then
+ if type(auraInfo) == "table" and canAccessTable(auraInfo) then
for index, info in ipairs(auraInfo) do
activeAuraCount = index
diff --git a/Runtime.lua b/Runtime.lua
index 642e6992..e1e85f59 100644
--- a/Runtime.lua
+++ b/Runtime.lua
@@ -66,8 +66,9 @@ local function applyBlizzardFrameState(frame)
return
end
- if not frame:IsShown() then frame:Show() end
- ns.FrameUtil.LazySetAlpha(frame, _desiredAlpha)
+ if frame:IsShown() then
+ ns.FrameUtil.LazySetAlpha(frame, _desiredAlpha)
+ end
end
--- Enforces the current desired visibility and alpha on all Blizzard frames.
diff --git a/Tests/ECM_Runtime_spec.lua b/Tests/ECM_Runtime_spec.lua
index 4a50e4ed..f3c4436e 100644
--- a/Tests/ECM_Runtime_spec.lua
+++ b/Tests/ECM_Runtime_spec.lua
@@ -929,7 +929,7 @@ describe("ECM.Runtime layout system", function()
"CooldownViewerSettings should still be hooked once after second tick")
end)
- it("still enforces Blizzard frame state after setup is complete", function()
+ it("does not re-show externally hidden Blizzard frames after setup is complete", function()
local frames = createAllBlizzardFrames()
ns.Runtime.Enable(fakeAddon)
@@ -940,10 +940,9 @@ describe("ECM.Runtime layout system", function()
frames[ns.Constants.BLIZZARD_FRAMES[1]]:Hide()
assert.is_false(frames[ns.Constants.BLIZZARD_FRAMES[1]]:IsShown())
- -- Enforcement tick should re-show it
ticker.callback()
- assert.is_true(frames[ns.Constants.BLIZZARD_FRAMES[1]]:IsShown(),
- "Enforcement should re-show externally hidden Blizzard frame")
+ assert.is_false(frames[ns.Constants.BLIZZARD_FRAMES[1]]:IsShown(),
+ "Enforcement should not re-show externally hidden Blizzard frame")
end)
it("continues setup when frames appear late", function()
diff --git a/Tests/Modules/ExternalBars_spec.lua b/Tests/Modules/ExternalBars_spec.lua
index dad78439..55790396 100644
--- a/Tests/Modules/ExternalBars_spec.lua
+++ b/Tests/Modules/ExternalBars_spec.lua
@@ -493,7 +493,7 @@ describe("ExternalBars real source", function()
return false
end
_G.canaccesstable = function(value)
- return type(value) == "table"
+ return type(value) == "table" and not rawget(value, "__inaccessible")
end
_G.C_UnitAuras = {
GetAuraDataByAuraInstanceID = function(_, auraInstanceID)
@@ -686,6 +686,24 @@ describe("ExternalBars real source", function()
}, logEntry.payload.auras[1])
end)
+ it("skips inaccessible Blizzard aura info tables", function()
+ local inaccessibleAuraInfo = setmetatable({}, {
+ __index = function()
+ error("attempted to iterate inaccessible auraInfo")
+ end,
+ })
+ inaccessibleAuraInfo.__inaccessible = true
+
+ viewer.auraInfo = inaccessibleAuraInfo
+
+ assert.has_no.errors(function()
+ ExternalBars:OnExternalAurasUpdated()
+ end)
+
+ assert.are.equal(0, ExternalBars._activeAuraCount)
+ assert.same({ "ExternalBars:UpdateAuras" }, requestLayoutReasons)
+ end)
+
it("emits hook diagnostics when the Blizzard viewer is missing", function()
debugLoggingEnabled = true
_G.ExternalDefensivesFrame = nil
diff --git a/scripts/publish-tag-toc.ps1 b/scripts/publish-tag-toc.ps1
deleted file mode 100644
index fddc9971..00000000
--- a/scripts/publish-tag-toc.ps1
+++ /dev/null
@@ -1,188 +0,0 @@
-param(
- [string]$TocPath = "EnhancedCooldownManager.toc",
- [string]$Remote = "origin",
- [Alias("TagMessage", "ReleaseMessage")]
- [string]$Message,
- [switch]$ShowReleasePopup
-)
-
-$ErrorActionPreference = "Stop"
-Set-StrictMode -Version Latest
-
-if ([string]::IsNullOrWhiteSpace($Message)) {
- throw "A non-empty -Message is required. The release tag annotation is used for the GitHub release and published changelog."
-}
-
-function Invoke-Git {
- param(
- [Parameter(Mandatory = $true)]
- [string[]]$Arguments
- )
-
- & git @Arguments
- if ($LASTEXITCODE -ne 0) {
- throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
- }
-}
-
-function Get-GitTagTarget {
- param(
- [Parameter(Mandatory = $true)]
- [string]$TagName
- )
-
- $tagTargetOutput = & git rev-parse "refs/tags/$TagName^{}" 2>&1
- if ($LASTEXITCODE -ne 0) {
- throw "Failed resolving target for tag '$TagName': $($tagTargetOutput -join "`n")"
- }
-
- return ($tagTargetOutput | Select-Object -First 1).ToString().Trim()
-}
-
-function Get-GitHubWorkflowsUrl {
- param(
- [Parameter(Mandatory = $true)]
- [string]$RemoteName
- )
-
- $remoteUrlOutput = & git remote get-url $RemoteName 2>&1
- if ($LASTEXITCODE -ne 0) {
- Write-Host "Could not resolve remote '$RemoteName' URL; skipping browser open." -ForegroundColor Yellow
- return $null
- }
-
- $remoteUrl = ($remoteUrlOutput | Select-Object -First 1).ToString().Trim()
- if ([string]::IsNullOrWhiteSpace($remoteUrl)) {
- Write-Host "Remote '$RemoteName' returned an empty URL; skipping browser open." -ForegroundColor Yellow
- return $null
- }
-
- $repoPath = $null
- if ($remoteUrl -match '^https?://github\.com/(?[^/]+/[^/]+?)(?:\.git)?/?$') {
- $repoPath = $Matches['repo']
- } elseif ($remoteUrl -match '^git@github\.com:(?[^/]+/[^/]+?)(?:\.git)?$') {
- $repoPath = $Matches['repo']
- } elseif ($remoteUrl -match '^ssh://git@github\.com/(?[^/]+/[^/]+?)(?:\.git)?/?$') {
- $repoPath = $Matches['repo']
- }
-
- if (-not $repoPath) {
- Write-Host "Remote '$RemoteName' is not a supported GitHub URL ('$remoteUrl'); skipping browser open." -ForegroundColor Yellow
- return $null
- }
-
- return "https://github.com/$repoPath/actions"
-}
-
-function Open-GitHubWorkflowsPage {
- param(
- [Parameter(Mandatory = $true)]
- [string]$RemoteName
- )
-
- $workflowsUrl = Get-GitHubWorkflowsUrl -RemoteName $RemoteName
- if (-not $workflowsUrl) {
- return
- }
-
- Write-Host "Opening workflows page: $workflowsUrl"
- try {
- Start-Process $workflowsUrl | Out-Null
- } catch {
- Write-Host "Failed to open browser for workflows page: $($_.Exception.Message)" -ForegroundColor Yellow
- }
-}
-
-if (-not (Test-Path -LiteralPath $TocPath)) {
- throw "TOC file not found: $TocPath"
-}
-
-$versionMatch = Select-String -Path $TocPath -Pattern '^\s*##\s*Version:\s*(.+?)\s*$' | Select-Object -First 1
-if (-not $versionMatch) {
- throw "Could not find a '## Version:' line in $TocPath"
-}
-
-$version = $versionMatch.Matches[0].Groups[1].Value.Trim()
-if ([string]::IsNullOrWhiteSpace($version)) {
- throw "Parsed an empty version from $TocPath"
-}
-
-Write-Host "TOC version: $version"
-
-function Set-ReleasePopupVersion {
- param(
- [Parameter(Mandatory)]
- [string]$Version,
- [string]$ConstantsPath = "Constants.lua"
- )
-
- if (-not (Test-Path -LiteralPath $ConstantsPath)) {
- throw "Constants file not found: $ConstantsPath"
- }
-
- $content = Get-Content -LiteralPath $ConstantsPath -Raw
- $pattern = '(RELEASE_POPUP_VERSION\s*=\s*")[^"]*(")'
- if ($content -notmatch 'RELEASE_POPUP_VERSION') {
- throw "Could not find RELEASE_POPUP_VERSION in $ConstantsPath"
- }
-
- $newContent = $content -replace $pattern, "`${1}$Version`${2}"
- if ($newContent -eq $content) {
- Write-Host "RELEASE_POPUP_VERSION is already '$Version' in $ConstantsPath"
- return $false
- }
-
- Set-Content -LiteralPath $ConstantsPath -Value $newContent -NoNewline
- Write-Host "Updated RELEASE_POPUP_VERSION to '$Version' in $ConstantsPath"
- return $true
-}
-
-if ($ShowReleasePopup) {
- $releasePopupVersionChanged = Set-ReleasePopupVersion -Version $version
- if ($releasePopupVersionChanged) {
- Invoke-Git -Arguments @("add", "Constants.lua")
- Invoke-Git -Arguments @("commit", "-m", "Set release popup version to $version")
- }
-}
-
-Invoke-Git -Arguments @("rev-parse", "--is-inside-work-tree")
-
-& git show-ref --verify --quiet "refs/tags/$version"
-$localTagExists = $LASTEXITCODE -eq 0
-if (-not $localTagExists -and $LASTEXITCODE -ne 1) {
- throw "Failed checking local tag existence for '$version'."
-}
-
-$remoteQuery = & git ls-remote --tags --refs $Remote "refs/tags/$version" 2>&1
-if ($LASTEXITCODE -ne 0) {
- throw "Failed querying remote '$Remote' for tag '$version': $($remoteQuery -join "`n")"
-}
-$remoteTagExists = -not [string]::IsNullOrWhiteSpace(($remoteQuery -join "`n"))
-
-if ($remoteTagExists) {
- throw "Tag '$version' already exists on '$Remote'. Its release notes are already locked in. Delete the remote tag/release manually or bump the version before publishing again."
-}
-
-if (-not $localTagExists) {
- Write-Host "Creating local tag '$version'"
- Write-Host "Using tag/release message: $Message"
- Invoke-Git -Arguments @("tag", "-a", $version, "-m", $Message)
- $localTagExists = $true
-} else {
- Write-Host "Local tag '$version' already exists." -ForegroundColor Yellow
-
- $tagTarget = Get-GitTagTarget -TagName $version
- Write-Host "Replacing local tag '$version' so it exactly matches the provided release message before pushing."
- Invoke-Git -Arguments @("tag", "-d", $version)
- Write-Host "Using tag/release message: $Message"
- Invoke-Git -Arguments @("tag", "-a", $version, "-m", $Message, $tagTarget)
-}
-
-if (-not $localTagExists) {
- throw "Cannot push '$version' because no local tag was found."
-}
-
-Write-Host "Pushing tag '$version' to '$Remote'"
-Invoke-Git -Arguments @("push", $Remote, "refs/tags/$version")
-Write-Host "Done." -ForegroundColor Green
-Open-GitHubWorkflowsPage -RemoteName $Remote
diff --git a/scripts/start-release.ps1 b/scripts/start-release.ps1
new file mode 100644
index 00000000..ff46033b
--- /dev/null
+++ b/scripts/start-release.ps1
@@ -0,0 +1,391 @@
+param(
+ [string]$TocPath = "EnhancedCooldownManager.toc",
+ [string]$Remote = "origin",
+ [string]$Message,
+ [string]$MessagePath,
+ [switch]$ShowReleasePopup,
+ [switch]$DryRun
+)
+
+$ErrorActionPreference = "Stop"
+Set-StrictMode -Version Latest
+
+function Invoke-Git {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Arguments
+ )
+
+ & git @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Invoke-Gh {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Arguments
+ )
+
+ & gh @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "gh $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
+ }
+}
+
+function Get-GitOutput {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string[]]$Arguments
+ )
+
+ $output = & git @Arguments 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "git $($Arguments -join ' ') failed: $($output -join "`n")"
+ }
+
+ return ($output | Select-Object -First 1).ToString().Trim()
+}
+
+function Test-GitRefExists {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Ref
+ )
+
+ & git show-ref --verify --quiet $Ref
+ if ($LASTEXITCODE -eq 0) {
+ return $true
+ }
+
+ if ($LASTEXITCODE -eq 1) {
+ return $false
+ }
+
+ throw "Failed checking git ref '$Ref'."
+}
+
+function Get-RemoteTagTarget {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Version
+ )
+
+ $remoteQuery = & git ls-remote --tags $Remote "refs/tags/$Version" "refs/tags/$Version^{}" 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed querying remote '$Remote' for tag '$Version': $($remoteQuery -join "`n")"
+ }
+
+ $lines = @($remoteQuery | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ if ($lines.Count -eq 0) {
+ return $null
+ }
+
+ $peeledRef = "refs/tags/$Version^{}"
+ foreach ($line in $lines) {
+ $parts = $line -split "\s+"
+ if ($parts.Count -ge 2 -and $parts[1] -eq $peeledRef) {
+ return $parts[0]
+ }
+ }
+
+ $tagRef = "refs/tags/$Version"
+ foreach ($line in $lines) {
+ $parts = $line -split "\s+"
+ if ($parts.Count -ge 2 -and $parts[1] -eq $tagRef) {
+ return $parts[0]
+ }
+ }
+
+ throw "Could not parse remote tag '$Version' from '$Remote': $($remoteQuery -join "`n")"
+}
+
+function Get-ReleaseMessage {
+ if (-not [string]::IsNullOrWhiteSpace($Message) -and -not [string]::IsNullOrWhiteSpace($MessagePath)) {
+ throw "Use either -Message or -MessagePath, not both."
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($MessagePath)) {
+ if (-not (Test-Path -LiteralPath $MessagePath)) {
+ throw "Release message file not found: $MessagePath"
+ }
+
+ return Get-Content -LiteralPath $MessagePath -Raw
+ }
+
+ return $Message
+}
+
+function Get-GitHubWorkflowsUrl {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$RemoteName
+ )
+
+ $remoteUrlOutput = & git remote get-url $RemoteName 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host "Could not resolve remote '$RemoteName' URL; skipping browser open." -ForegroundColor Yellow
+ return $null
+ }
+
+ $remoteUrl = ($remoteUrlOutput | Select-Object -First 1).ToString().Trim()
+ if ([string]::IsNullOrWhiteSpace($remoteUrl)) {
+ Write-Host "Remote '$RemoteName' returned an empty URL; skipping browser open." -ForegroundColor Yellow
+ return $null
+ }
+
+ $repoPath = $null
+ if ($remoteUrl -match '^https?://github\.com/(?[^/]+/[^/]+?)(?:\.git)?/?$') {
+ $repoPath = $Matches["repo"]
+ } elseif ($remoteUrl -match '^git@github\.com:(?[^/]+/[^/]+?)(?:\.git)?$') {
+ $repoPath = $Matches["repo"]
+ } elseif ($remoteUrl -match '^ssh://git@github\.com/(?[^/]+/[^/]+?)(?:\.git)?/?$') {
+ $repoPath = $Matches["repo"]
+ }
+
+ if (-not $repoPath) {
+ Write-Host "Remote '$RemoteName' is not a supported GitHub URL ('$remoteUrl'); skipping browser open." -ForegroundColor Yellow
+ return $null
+ }
+
+ return "https://github.com/$repoPath/actions"
+}
+
+function Open-GitHubWorkflowsPage {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$RemoteName
+ )
+
+ $workflowsUrl = Get-GitHubWorkflowsUrl -RemoteName $RemoteName
+ if (-not $workflowsUrl) {
+ return
+ }
+
+ Write-Host "Opening workflows page: $workflowsUrl"
+ try {
+ Start-Process $workflowsUrl | Out-Null
+ } catch {
+ Write-Host "Failed to open browser for workflows page: $($_.Exception.Message)" -ForegroundColor Yellow
+ }
+}
+
+function Set-ReleasePopupVersion {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Version,
+ [string]$ConstantsPath = "Constants.lua"
+ )
+
+ if (-not (Test-Path -LiteralPath $ConstantsPath)) {
+ throw "Constants file not found: $ConstantsPath"
+ }
+
+ $content = Get-Content -LiteralPath $ConstantsPath -Raw
+ $pattern = '(RELEASE_POPUP_VERSION\s*=\s*")[^"]*(")'
+ if ($content -notmatch 'RELEASE_POPUP_VERSION') {
+ throw "Could not find RELEASE_POPUP_VERSION in $ConstantsPath"
+ }
+
+ $newContent = $content -replace $pattern, "`${1}$Version`${2}"
+ if ($newContent -eq $content) {
+ Write-Host "RELEASE_POPUP_VERSION is already '$Version' in $ConstantsPath"
+ return $false
+ }
+
+ if ($DryRun) {
+ Write-Host "Dry run: would update RELEASE_POPUP_VERSION to '$Version' in $ConstantsPath"
+ return $false
+ }
+
+ Set-Content -LiteralPath $ConstantsPath -Value $newContent -NoNewline
+ Write-Host "Updated RELEASE_POPUP_VERSION to '$Version' in $ConstantsPath"
+ return $true
+}
+
+function Assert-RemoteReleaseAvailable {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Version,
+ [Parameter(Mandatory)]
+ [string]$Commit
+ )
+
+ $remoteTarget = Get-RemoteTagTarget -Version $Version
+ if ($remoteTarget) {
+ if ($remoteTarget -ne $Commit) {
+ throw "Tag '$Version' already exists on '$Remote' but points to '$remoteTarget', not '$Commit'. Bump the TOC version before publishing again."
+ }
+
+ Write-Host "Remote tag '$Version' already points at this commit; release dispatch can retry."
+ }
+
+ $releaseQuery = & gh release view $Version --json tagName 2>&1
+ if ($LASTEXITCODE -eq 0) {
+ throw "GitHub Release '$Version' already exists. Bump the TOC version before publishing again."
+ }
+
+ if (($releaseQuery -join "`n") -notmatch '(?i)not found') {
+ throw "Failed checking GitHub Release '$Version': $($releaseQuery -join "`n")"
+ }
+}
+
+function Get-TocVersionFromContent {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Content,
+ [Parameter(Mandatory)]
+ [string]$Source
+ )
+
+ $match = [regex]::Match($Content, '(?m)^\s*##\s*Version:\s*(.+?)\s*$')
+ if (-not $match.Success) {
+ throw "Could not find a '## Version:' line in $Source"
+ }
+
+ $version = $match.Groups[1].Value.Trim()
+ if ([string]::IsNullOrWhiteSpace($version)) {
+ throw "Parsed an empty version from $Source"
+ }
+
+ return $version
+}
+
+function Get-CommittedTocVersion {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Path
+ )
+
+ $gitPath = $Path -replace '\\', '/'
+ $content = & git show "HEAD:$gitPath" 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "Could not read committed TOC file from HEAD: $($content -join "`n")"
+ }
+
+ return Get-TocVersionFromContent -Content ($content -join "`n") -Source "HEAD:$Path"
+}
+
+function Sync-ReleaseBranch {
+ param(
+ [Parameter(Mandatory)]
+ [string]$BranchName
+ )
+
+ if ($DryRun) {
+ $remoteQuery = & git ls-remote --heads $Remote "refs/heads/$BranchName" 2>&1
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed querying remote '$Remote' for branch '$BranchName': $($remoteQuery -join "`n")"
+ }
+
+ if ([string]::IsNullOrWhiteSpace(($remoteQuery -join "`n"))) {
+ throw "Remote branch '$Remote/$BranchName' was not found. Push the branch before dispatching a release."
+ }
+
+ Write-Host "Dry run: would fetch '$BranchName', verify branch history, and push only local-ahead commits."
+ return
+ }
+
+ Write-Host "Fetching '$BranchName' from '$Remote'"
+ Invoke-Git -Arguments @("fetch", $Remote, $BranchName)
+
+ $remoteRef = "refs/remotes/$Remote/$BranchName"
+ if (-not (Test-GitRefExists -Ref $remoteRef)) {
+ throw "Remote branch '$Remote/$BranchName' was not found. Push the branch before dispatching a release."
+ }
+
+ $localCommit = Get-GitOutput -Arguments @("rev-parse", "HEAD")
+ $remoteCommit = Get-GitOutput -Arguments @("rev-parse", $remoteRef)
+ $mergeBase = Get-GitOutput -Arguments @("merge-base", "HEAD", $remoteRef)
+
+ if ($localCommit -eq $remoteCommit) {
+ Write-Host "Branch '$BranchName' is already pushed."
+ return
+ }
+
+ if ($mergeBase -eq $remoteCommit) {
+ Write-Host "Pushing '$BranchName' to '$Remote'"
+ Invoke-Git -Arguments @("push", $Remote, "HEAD:refs/heads/$BranchName")
+ return
+ }
+
+ if ($mergeBase -eq $localCommit) {
+ throw "Local branch '$BranchName' is behind '$Remote/$BranchName'. Pull or rebase before releasing."
+ }
+
+ throw "Local branch '$BranchName' has diverged from '$Remote/$BranchName'. Resolve the branch history before releasing."
+}
+
+if ((Get-GitOutput -Arguments @("rev-parse", "--is-inside-work-tree")) -ne "true") {
+ throw "Current directory is not inside a git worktree."
+}
+
+if (-not (Test-Path -LiteralPath $TocPath)) {
+ throw "TOC file not found: $TocPath"
+}
+
+$releaseMessage = Get-ReleaseMessage
+if ([string]::IsNullOrWhiteSpace($releaseMessage)) {
+ throw "A non-empty -Message or -MessagePath is required. The text is used for the packager changelog and GitHub Release."
+}
+
+$version = Get-TocVersionFromContent -Content (Get-Content -LiteralPath $TocPath -Raw) -Source $TocPath
+$committedVersion = Get-CommittedTocVersion -Path $TocPath
+if ($version -ne $committedVersion) {
+ throw "Working-copy TOC version '$version' differs from committed TOC version '$committedVersion'. Commit the TOC version change before dispatching the release."
+}
+
+if (-not $version.StartsWith("v")) {
+ throw "TOC version '$version' must start with 'v'."
+}
+
+Write-Host "TOC version: $version"
+
+$branch = Get-GitOutput -Arguments @("branch", "--show-current")
+if ([string]::IsNullOrWhiteSpace($branch)) {
+ throw "Releases must be dispatched from a named branch, not detached HEAD."
+}
+
+$isPrerelease = $version.Contains("-")
+if ($isPrerelease) {
+ $versionLower = $version.ToLowerInvariant()
+ if (-not $versionLower.Contains("alpha") -and -not $versionLower.Contains("beta")) {
+ throw "Prerelease TOC version '$version' must include 'alpha' or 'beta' so external uploads are not marked stable."
+ }
+}
+
+if (-not $isPrerelease -and $branch -ne "main") {
+ throw "Stable releases must come from 'main'. '$version' is a stable version but the current branch is '$branch'."
+}
+
+$commit = Get-GitOutput -Arguments @("rev-parse", "HEAD")
+
+Invoke-Gh -Arguments @("auth", "status")
+
+if ($ShowReleasePopup) {
+ $releasePopupVersionChanged = Set-ReleasePopupVersion -Version $version
+ if ($releasePopupVersionChanged) {
+ Invoke-Git -Arguments @("add", "Constants.lua")
+ Invoke-Git -Arguments @("commit", "-m", "Set release popup version to $version")
+ }
+}
+
+Assert-RemoteReleaseAvailable -Version $version -Commit $commit
+Sync-ReleaseBranch -BranchName $branch
+
+$inputs = @{ release_notes = $releaseMessage } | ConvertTo-Json -Compress
+
+if ($DryRun) {
+ Write-Host "Dry run: would dispatch release.yml for '$version' from '$branch'."
+ exit 0
+}
+
+Write-Host "Dispatching release.yml for '$version' from '$branch'"
+$inputs | gh workflow run release.yml --ref $branch --json
+if ($LASTEXITCODE -ne 0) {
+ throw "gh workflow run release.yml failed with exit code $LASTEXITCODE."
+}
+
+Write-Host "Release workflow dispatched." -ForegroundColor Green
+Open-GitHubWorkflowsPage -RemoteName $Remote
From b6646d9fbe80f2063569816f8b1d1ea41d22d572 Mon Sep 17 00:00:00 2001
From: Argi <15852038+argium@users.noreply.github.com>
Date: Sat, 2 May 2026 19:30:41 +1000
Subject: [PATCH 53/53] set action name
---
.github/workflows/release.yml | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0f8a4ea3..69849f71 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,8 +1,13 @@
name: Package Release
+run-name: Package Release ${{ inputs.version }}
on:
workflow_dispatch:
inputs:
+ version:
+ description: Release version from EnhancedCooldownManager.toc.
+ required: true
+ type: string
release_notes:
description: Release notes for the packager changelog and GitHub Release.
required: true
@@ -42,6 +47,7 @@ jobs:
id: release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ INPUT_VERSION: ${{ inputs.version }}
RELEASE_NOTES: ${{ inputs.release_notes }}
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
@@ -64,6 +70,12 @@ jobs:
exit 1
fi
+ INPUT_VERSION=$(printf '%s' "$INPUT_VERSION" | tr -d '[:space:]')
+ if [ "$INPUT_VERSION" != "$VERSION" ]; then
+ echo "::error::Release input version '$INPUT_VERSION' must match ${ADDON_NAME}.toc version '$VERSION'."
+ exit 1
+ fi
+
if [[ "$VERSION" != v* ]]; then
echo "::error::TOC version '$VERSION' must start with 'v'."
exit 1