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 @@ + +