-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathSpellColors.lua
More file actions
1144 lines (1010 loc) · 37.5 KB
/
Copy pathSpellColors.lua
File metadata and controls
1144 lines (1010 loc) · 37.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Enhanced Cooldown Manager addon for World of Warcraft
-- Author: Argium
-- Licensed under the GNU General Public License v3.0
--
-- SpellColors: Key construction, normalization, and matching for spell-color
-- entries. Backed by a multi-tier key system where each spell can be identified
-- by name, spell ID, cooldown ID, or texture file ID. Includes persistence and
-- public APIs for per-spell color customization.
---@alias ECM_SpellColorKeyField "spellName"|"spellID"|"cooldownID"|"textureFileID"
---@class ECM_SpellColorKey Spell color identity assembled from one or more supported key fields.
---@field keyType ECM_SpellColorKeyField Primary key field type.
---@field primaryKey string|number Primary key value.
---@field spellName string|nil Spell name key.
---@field spellID number|nil Spell ID key.
---@field cooldownID number|nil Cooldown ID key.
---@field textureFileID number|nil Texture file ID key.
---@field Matches fun(self: ECM_SpellColorKey, other: ECM_SpellColorKey|table|nil): boolean Gets whether another key identifies the same logical spell color entry.
---@field Merge fun(self: ECM_SpellColorKey, other: ECM_SpellColorKey|table|nil): ECM_SpellColorKey|nil Merges another matching key into this key.
---@field ToString fun(self: ECM_SpellColorKey): string Gets a debug string for the key.
---@field ToArray fun(self: ECM_SpellColorKey): table Gets key values in tier order.
---@class ECM_SpellColorKeyType : ECM_SpellColorKey Runtime metatable for spell color keys.
---@class ECM_SpellColorStore Spell color storage facade for one options scope.
---@field _scope string Store scope name.
---@field _configAccessor (fun(): table|nil)|nil Optional config accessor override.
---@field _discoveredKeys ECM_SpellColorKey[] Runtime-discovered keys from visible bars.
---@field _SetConfigAccessor fun(self: ECM_SpellColorStore, accessor: fun(): table|nil) Sets the config accessor used by this store.
---@field GetColorByKey fun(self: ECM_SpellColorStore, key: ECM_SpellColorKey|table|nil): ECM_Color|nil Gets the configured color for a key.
---@field GetAllColorEntries fun(self: ECM_SpellColorStore): table[] Gets deduplicated color entries for the current class/spec.
---@field SetColorByKey fun(self: ECM_SpellColorStore, key: ECM_SpellColorKey|table|nil, color: ECM_Color) Sets the configured color for a key.
---@field SetDefaultColor fun(self: ECM_SpellColorStore, color: ECM_Color) Sets the default color for this store scope.
---@field ResetColorByKey fun(self: ECM_SpellColorStore, key: ECM_SpellColorKey|table|nil): boolean, boolean, boolean, boolean Resets the configured color for a key.
---@field ReconcileAllKeys fun(self: ECM_SpellColorStore, keys: ECM_SpellColorKey[]|nil): number Reconciles and repairs persisted key metadata.
---@field RemoveEntriesByKeys fun(self: ECM_SpellColorStore, keys: (ECM_SpellColorKey|table)[]): ECM_SpellColorKey[] Removes persisted and discovered entries matching keys.
---@field DiscoverBar fun(self: ECM_SpellColorStore, frame: ECM_BuffBarMixin) Registers a visible bar in the discovered key cache.
---@field ClearDiscoveredKeys fun(self: ECM_SpellColorStore) Clears the runtime discovered key cache.
---@field ClearCurrentSpecColors fun(self: ECM_SpellColorStore): number Clears persisted colors for the current class/spec.
---@field GetColorForBar fun(self: ECM_SpellColorStore, frame: ECM_BuffBarMixin|Frame): table|nil Gets the configured color for a bar frame.
---@field GetDefaultColor fun(self: ECM_SpellColorStore): table Gets the default color for this store scope.
local _, ns = ...
local C = ns.Constants
local SpellColors = {}
ns.SpellColors = SpellColors
local SpellColorStore = {}
SpellColorStore.__index = SpellColorStore
local FrameUtil = ns.FrameUtil
local DEFAULT_SCOPE = C.SCOPE_BUFFBARS
local _storesByScope = {}
local KEY_FIELDS = {
{ storeKey = "byName", keyType = "spellName", valueType = "string" },
{ storeKey = "bySpellID", keyType = "spellID", valueType = "number" },
{ storeKey = "byCooldownID", keyType = "cooldownID", valueType = "number" },
{ storeKey = "byTexture", keyType = "textureFileID", valueType = "number", alias = "textureId" },
}
local KEY_DEFS = {}
local KEY_TYPE_TO_STORE = {}
local KEY_TYPES = {}
for i, field in ipairs(KEY_FIELDS) do
KEY_DEFS[i] = field.storeKey
KEY_TYPE_TO_STORE[field.storeKey] = field.keyType
KEY_TYPES[field.keyType] = field
end
local function normalizeScope(scope)
return type(scope) == "string" and scope or DEFAULT_SCOPE
end
---------------------------------------------------------------------------
-- Key validation
---------------------------------------------------------------------------
--- Returns k if it is a valid, non-secret string or number; nil otherwise.
local function validateKey(k)
local t = type(k)
if (t == "string" or t == "number") and not issecretvalue(k) then
return k
end
return nil
end
---------------------------------------------------------------------------
-- SpellColorKeyType class
---------------------------------------------------------------------------
local SpellColorKeyType = {}
SpellColorKeyType.__index = SpellColorKeyType
---@param spellName string|nil
---@param spellID number|nil
---@param cooldownID number|nil
---@param textureFileID number|nil
---@param preferredType "spellName"|"spellID"|"cooldownID"|"textureFileID"|nil
---@return ECM_SpellColorKey|nil
local function buildKey(spellName, spellID, cooldownID, textureFileID, preferredType)
local values = { spellName = spellName, spellID = spellID, cooldownID = cooldownID, textureFileID = textureFileID }
local keyType = KEY_TYPES[preferredType] and preferredType or nil
local primaryKey = keyType and values[keyType] or nil
if not primaryKey then
for _, field in ipairs(KEY_FIELDS) do
primaryKey = values[field.keyType]
if primaryKey then
keyType = field.keyType
break
end
end
end
if not (keyType and primaryKey) then
return nil
end
return setmetatable({
keyType = keyType,
primaryKey = primaryKey,
spellName = spellName,
spellID = spellID,
cooldownID = cooldownID,
textureFileID = textureFileID,
}, SpellColorKeyType)
end
---@param key ECM_SpellColorKey|table|nil
---@return ECM_SpellColorKey|nil
local function normalizeKey(key)
if type(key) ~= "table" then
return nil
end
local values = {}
for _, field in ipairs(KEY_FIELDS) do
values[field.keyType] = validateKey(key[field.keyType] or (field.alias and key[field.alias] or nil))
end
local keyType = KEY_TYPES[key.keyType] and key.keyType or nil
local primaryKey = validateKey(key.primaryKey)
local field = KEY_TYPES[keyType]
if field and type(primaryKey) == field.valueType and not values[keyType] then
values[keyType] = primaryKey
end
return buildKey(values.spellName, values.spellID, values.cooldownID, values.textureFileID, keyType)
end
---@param a ECM_SpellColorKey|nil
---@param b ECM_SpellColorKey|nil
---@return boolean
local function keysMatch(a, b)
if not (a and b) then
return false
end
for _, field in ipairs(KEY_FIELDS) do
local keyType = field.keyType
if keyType ~= "textureFileID" and a[keyType] and b[keyType] and a[keyType] == b[keyType] then
return true
end
end
local aTextureOnly = (a.spellName == nil and a.spellID == nil and a.cooldownID == nil)
local bTextureOnly = (b.spellName == nil and b.spellID == nil and b.cooldownID == nil)
if
(aTextureOnly or bTextureOnly)
and a.textureFileID
and b.textureFileID
and a.textureFileID == b.textureFileID
then
return true
end
return false
end
---@param base ECM_SpellColorKey|nil
---@param other ECM_SpellColorKey|nil
---@return ECM_SpellColorKey|nil
local function mergeKeys(base, other)
if base == nil then
return other
end
if other == nil then
return base
end
if not keysMatch(base, other) then
return nil
end
return buildKey(
base.spellName or other.spellName,
base.spellID or other.spellID,
base.cooldownID or other.cooldownID,
base.textureFileID or other.textureFileID,
nil
)
end
---@param spellName string|nil
---@param spellID number|nil
---@param cooldownID number|nil
---@param textureFileID number|nil
---@return ECM_SpellColorKey|nil
local function makeKey(spellName, spellID, cooldownID, textureFileID)
return buildKey(
validateKey(spellName),
validateKey(spellID),
validateKey(cooldownID),
validateKey(textureFileID),
nil
)
end
---------------------------------------------------------------------------
-- SpellColorKeyType methods
---------------------------------------------------------------------------
---@param other ECM_SpellColorKey|table|nil
---@return boolean
function SpellColorKeyType:Matches(other)
return keysMatch(self, normalizeKey(other))
end
---@param other ECM_SpellColorKey|table|nil
---@return ECM_SpellColorKey|nil
function SpellColorKeyType:Merge(other)
return mergeKeys(self, normalizeKey(other))
end
function SpellColorKeyType:ToString()
return string.format(
"SpellColorKey{type=%s, spellName=%s, spellID=%s, cooldownID=%s, textureFileID=%s}",
tostring(self.keyType),
tostring(self.spellName),
tostring(self.spellID),
tostring(self.cooldownID),
tostring(self.textureFileID)
)
end
---@return table values Key values in tier order.
function SpellColorKeyType:ToArray()
return { self.spellName, self.spellID, self.cooldownID, self.textureFileID }
end
---------------------------------------------------------------------------
-- Public key API
---------------------------------------------------------------------------
SpellColors.MakeKey = makeKey
SpellColors.NormalizeKey = normalizeKey
--- Returns true when two keys identify the same logical spell-color entry.
--- Both operands are normalized first so callers can pass either a
--- `ECM_SpellColorKey` or a raw payload accepted by `NormalizeKey`.
---@param left ECM_SpellColorKey|table|nil
---@param right ECM_SpellColorKey|table|nil
---@return boolean
function SpellColors.KeysMatch(left, right)
return keysMatch(normalizeKey(left), normalizeKey(right))
end
--- Merges identifiers from two matching keys into a single normalized key.
--- Both operands are normalized first so callers can pass either a
--- `ECM_SpellColorKey` or a raw payload accepted by `NormalizeKey`.
---@param base ECM_SpellColorKey|table|nil
---@param other ECM_SpellColorKey|table|nil
---@return ECM_SpellColorKey|nil
function SpellColors.MergeKeys(base, other)
return mergeKeys(normalizeKey(base), normalizeKey(other))
end
-- WoW uses Lua 5.1 (global `unpack`), busted tests use Lua 5.3+ (`table.unpack`).
---@diagnostic disable-next-line: undefined-field
local unpack = _G.unpack or table.unpack
---------------------------------------------------------------------------
-- Entry metadata helpers
---------------------------------------------------------------------------
local LEGACY_METADATA_FIELDS =
{ "keyType", "primaryKey", "spellName", "spellID", "cooldownID", "textureId", "textureFileID" }
---@param entry any
---@return number
local function entryTs(entry)
return (type(entry) == "table" and type(entry.t) == "number") and entry.t or 0
end
---@param color table|nil
---@return ECM_Color|nil
local function sanitizeColorValue(color)
if type(color) ~= "table" then
return nil
end
return { r = color.r, g = color.g, b = color.b, a = color.a or 1 }
end
---@param value table|nil
---@return boolean changed
local function scrubLegacyColorMetadata(value)
if type(value) ~= "table" then
return false
end
local changed = false
for _, field in ipairs(LEGACY_METADATA_FIELDS) do
if value[field] ~= nil then
value[field] = nil
changed = true
end
end
return changed
end
---@param normalized ECM_SpellColorKey|nil
---@return table|nil
local function buildEntryMeta(normalized)
if not normalized then
return nil
end
return {
keyType = normalized.keyType,
primaryKey = normalized.primaryKey,
spellName = normalized.spellName,
spellID = normalized.spellID,
cooldownID = normalized.cooldownID,
textureFileID = normalized.textureFileID,
}
end
---@param entry table|nil
---@param tierKeyType "spellName"|"spellID"|"cooldownID"|"textureFileID"
---@param rawKey string|number|nil
---@return ECM_SpellColorKey|nil
local function buildKeyFromEntry(entry, tierKeyType, rawKey)
if type(entry) ~= "table" or type(entry.value) ~= "table" then
return nil
end
local value = entry.value
local meta = type(entry.meta) == "table" and entry.meta or nil
local spellName = validateKey((meta and meta.spellName) or value.spellName)
local spellID = validateKey((meta and meta.spellID) or value.spellID)
local cooldownID = validateKey((meta and meta.cooldownID) or value.cooldownID)
local textureFileID =
validateKey((meta and (meta.textureFileID or meta.textureId)) or value.textureFileID or value.textureId)
local preferredType = ((meta and meta.keyType) or value.keyType or tierKeyType)
if not KEY_TYPES[preferredType] then
preferredType = tierKeyType
end
local validRawKey = validateKey(rawKey)
local rawField = KEY_TYPES[tierKeyType]
if rawField and type(validRawKey) == rawField.valueType then
if tierKeyType == "spellName" then
spellName = validRawKey
elseif tierKeyType == "spellID" then
spellID = validRawKey
elseif tierKeyType == "cooldownID" then
cooldownID = validRawKey
elseif tierKeyType == "textureFileID" then
textureFileID = validRawKey
end
end
return buildKey(spellName, spellID, cooldownID, textureFileID, preferredType)
end
---@param entry table|nil
---@param normalized ECM_SpellColorKey|nil
---@return boolean changed
local function normalizeEntryMetadata(entry, normalized)
if type(entry) ~= "table" or type(entry.value) ~= "table" or not normalized then
return false
end
local changed = scrubLegacyColorMetadata(entry.value)
local desired = assert(buildEntryMeta(normalized), "entry metadata required")
local current = type(entry.meta) == "table" and entry.meta or nil
if
not current
or current.keyType ~= desired.keyType
or current.primaryKey ~= desired.primaryKey
or current.spellName ~= desired.spellName
or current.spellID ~= desired.spellID
or current.cooldownID ~= desired.cooldownID
or current.textureFileID ~= desired.textureFileID
then
entry.meta = desired
changed = true
end
return changed
end
---@param value table|nil
---@return boolean
local function hasLegacyColorMetadata(value)
if type(value) ~= "table" then
return false
end
for _, field in ipairs(LEGACY_METADATA_FIELDS) do
if value[field] ~= nil then
return true
end
end
return false
end
--- Runtime cache of keys discovered from active bars during layout.
--- Merged into GetAllColorEntries so the options UI sees all visible bars
--- without reaching into BuffBars directly.
---@param store ECM_SpellColorStore
---@return ECM_SpellColorKey[]
local function getDiscoveredKeys(store)
local discoveredKeys = store._discoveredKeys
if not discoveredKeys then
discoveredKeys = {}
store._discoveredKeys = discoveredKeys
end
return discoveredKeys
end
---------------------------------------------------------------------------
-- Profile helpers
---------------------------------------------------------------------------
--- Returns the scope-specific default color fallback.
---@param scope string|nil
---@return ECM_Color
local function getScopeDefaultColor(scope)
local defaults = ns.defaults and ns.defaults.profile and ns.defaults.profile[normalizeScope(scope)]
local color = defaults and defaults.colors and defaults.colors.defaultColor
return color or ns.Constants.BUFFBARS_DEFAULT_COLOR
end
--- Ensures the color storage tables exist for the current class/spec.
---@param cfg table scope config table
---@return table|nil classSpecStores Keyed by KEY_DEFS field names; each value is the current class/spec storage table.
local function getCurrentClassSpecStores(cfg)
local _, _, classID = UnitClass("player")
local specID = GetSpecialization()
if not classID or not specID then
ns.DebugAssert(false, "SpellColors.getCurrentClassSpecStores - unable to determine player class/spec", {
classID = classID,
specID = specID,
})
return nil
end
local classSpecStores = {}
for _, def in ipairs(KEY_DEFS) do
cfg.colors[def][classID] = cfg.colors[def][classID] or {}
cfg.colors[def][classID][specID] = cfg.colors[def][classID][specID] or {}
classSpecStores[def] = cfg.colors[def][classID][specID]
end
return classSpecStores
end
--- Ensures nested tables exist for color storage.
---@param cfg table scope config table
---@param scope string|nil
local function ensureProfileIsSetup(cfg, scope)
if not cfg.colors then
cfg.colors = {
byName = {},
bySpellID = {},
byCooldownID = {},
byTexture = {},
cache = {},
defaultColor = getScopeDefaultColor(scope),
}
end
for _, def in ipairs(KEY_DEFS) do
if type(cfg.colors[def]) ~= "table" then
cfg.colors[def] = {}
end
end
if type(cfg.colors.cache) ~= "table" then
cfg.colors.cache = {}
end
if type(cfg.colors.defaultColor) ~= "table" then
cfg.colors.defaultColor = getScopeDefaultColor(scope)
end
end
--- Creates a spell-colour store bound to a single scope.
---@param scope string|nil
---@param configAccessor (fun(): table|nil)|nil
---@return ECM_SpellColorStore
function SpellColors.New(scope, configAccessor)
return setmetatable({
_scope = normalizeScope(scope),
_configAccessor = configAccessor,
_discoveredKeys = {},
}, SpellColorStore)
end
--- Returns the shared spell-colour store for a scope.
---@param scope string|nil
---@return ECM_SpellColorStore
function SpellColors.Get(scope)
local resolvedScope = normalizeScope(scope)
local store = _storesByScope[resolvedScope]
if not store then
store = SpellColors.New(resolvedScope)
_storesByScope[resolvedScope] = store
end
return store
end
-- Not used by production code; retained for tests that need to swap config sources after construction.
---@param accessor fun(): table|nil
function SpellColorStore:_SetConfigAccessor(accessor)
self._configAccessor = accessor
end
--- Returns the profile or scope config source table for a store, or nil if unavailable.
---@param store ECM_SpellColorStore
---@return table|nil source
local function configSource(store)
local source
if store._configAccessor then
source = store._configAccessor()
else
source = ns.Addon and ns.Addon.db and ns.Addon.db.profile or nil
end
if type(source) == "table" and type(source.profile) == "table" then
source = source.profile
end
return source
end
--- Returns the scoped config table for a store, or nil if unavailable.
---@param store ECM_SpellColorStore
---@return table|nil cfg
local function config(store)
local resolvedScope = store._scope
local source = configSource(store)
local cfg
if type(source) == "table" then
-- Treat the requested scope table as a valid profile signal so New(scope, accessor) works when tests seed only that scope.
local looksLikeProfile = type(source[resolvedScope]) == "table" or type(source[DEFAULT_SCOPE]) == "table"
if looksLikeProfile then
cfg = source[resolvedScope]
elseif resolvedScope == DEFAULT_SCOPE then
cfg = source
end
end
if type(cfg) ~= "table" then
ns.DebugAssert(false, "SpellColors.config - missing or invalid scope config", { scope = resolvedScope })
return nil
end
ensureProfileIsSetup(cfg, resolvedScope)
return cfg
end
---------------------------------------------------------------------------
-- Stamped entry helpers
---------------------------------------------------------------------------
--- Wraps a value with a write-timestamp.
local function stamp(value, meta)
return { value = value, t = time(), meta = meta }
end
--- Returns the underlying value from a stamped entry, or nil.
local function unwrap(entry)
if type(entry) == "table" and entry.value ~= nil then
return entry.value
end
return nil
end
--- Returns the timestamp from a stamped entry, or 0.
local function stampTs(entry)
return (type(entry) == "table" and type(entry.t) == "number") and entry.t or 0
end
---------------------------------------------------------------------------
-- Tier-table operations (inlined from former PriorityKeyMap)
---------------------------------------------------------------------------
--- Returns the 4 tier sub-tables for the current class/spec, or nil.
---@param store ECM_SpellColorStore
local function scopeTables(store)
local cfg = config(store)
if not cfg then
return nil
end
return getCurrentClassSpecStores(cfg)
end
--- Validates keys, returning an array of validated keys + the count of valid ones.
local function validateKeys(keys)
local vkeys = {}
local validCount = 0
for i = 1, #KEY_DEFS do
vkeys[i] = validateKey(keys[i])
if vkeys[i] then
validCount = validCount + 1
end
end
return vkeys, validCount
end
--- Looks up a value by trying keys in priority order (index 1 first).
---@param store ECM_SpellColorStore
local function storeGet(store, keys)
local tables = scopeTables(store)
if not tables then
return nil
end
for i = 1, #KEY_DEFS do
local k = validateKey(keys[i])
if k and tables[KEY_DEFS[i]] then
local entry = tables[KEY_DEFS[i]][k]
if entry then
return unwrap(entry)
end
end
end
return nil
end
--- Stores a value under all valid keys. Reuses the oldest existing stamped
--- wrapper to keep all tier references pointing to the same table.
---@param store ECM_SpellColorStore
local function storeSet(store, keys, value, meta)
local tables = scopeTables(store)
if not tables then
return
end
local vkeys = validateKeys(keys)
local winner, winnerTs = nil, -1
for i = 1, #KEY_DEFS do
local k = vkeys[i]
if k and tables[KEY_DEFS[i]] then
local existing = tables[KEY_DEFS[i]][k]
if type(existing) == "table" and existing.value ~= nil then
local t = stampTs(existing)
if t > winnerTs then
winner = existing
winnerTs = t
end
end
end
end
local entry = winner or stamp(value, meta)
if winner then
entry.value = value
entry.t = time()
entry.meta = meta
end
for i = 1, #KEY_DEFS do
local k = vkeys[i]
if k and tables[KEY_DEFS[i]] then
tables[KEY_DEFS[i]][k] = entry
end
end
end
--- Removes entries from all tier tables.
---@param store ECM_SpellColorStore
local function storeRemove(store, keys)
local tables = scopeTables(store)
local cleared = {}
for i = 1, #KEY_DEFS do
cleared[i] = false
local k = validateKey(keys[i])
if k and tables and tables[KEY_DEFS[i]] and tables[KEY_DEFS[i]][k] ~= nil then
tables[KEY_DEFS[i]][k] = nil
cleared[i] = true
end
end
return unpack(cleared)
end
--- Reconciles a single key set: finds the most-recently-written entry
--- across all tiers and propagates it to every valid tier that is missing
--- or outdated.
---@param store ECM_SpellColorStore
local function reconcile(store, keys)
local tables = scopeTables(store)
if not tables then
return false
end
local vkeys, validCount = validateKeys(keys)
if validCount < 2 then
return false
end
-- Find the winning entry (most recent timestamp).
local winner, winnerTs = nil, -1
for i = 1, #KEY_DEFS do
if vkeys[i] and tables[KEY_DEFS[i]] then
local entry = tables[KEY_DEFS[i]][vkeys[i]]
if entry then
local t = stampTs(entry)
if t > winnerTs then
winner = entry
winnerTs = t
end
end
end
end
if not winner then
return false
end
-- Propagate to every valid tier that is missing or outdated.
local changed = false
for i = 1, #KEY_DEFS do
if vkeys[i] and tables[KEY_DEFS[i]] then
local existing = tables[KEY_DEFS[i]][vkeys[i]]
if not existing or stampTs(existing) < winnerTs then
tables[KEY_DEFS[i]][vkeys[i]] = winner
changed = true
end
end
end
return changed
end
--- Reconciles a batch of key arrays.
---@param store ECM_SpellColorStore
local function reconcileAll(store, keysList)
local changed = 0
for _, keys in ipairs(keysList) do
if reconcile(store, keys) then
changed = changed + 1
end
end
return changed
end
---@param store ECM_SpellColorStore
---@return number changed
local function repairCurrentSpecStoreMetadata(store)
local cfg = config(store)
if not cfg then
return 0
end
local classSpecStores = getCurrentClassSpecStores(cfg)
if not classSpecStores then
return 0
end
local changed = 0
for _, scopeKey in ipairs(KEY_DEFS) do
local tierKeyType = KEY_TYPE_TO_STORE[scopeKey]
local storeTable = classSpecStores[scopeKey]
if type(storeTable) == "table" then
for rawKey, entry in pairs(storeTable) do
local normalized = buildKeyFromEntry(entry, tierKeyType, rawKey)
if normalized and normalizeEntryMetadata(entry, normalized) then
changed = changed + 1
elseif
type(entry) == "table"
and type(entry.value) == "table"
and scrubLegacyColorMetadata(entry.value)
then
changed = changed + 1
end
end
end
end
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 store ECM_SpellColorStore
---@param target ECM_SpellColorKey|nil
---@return boolean removed
local function removeMatchingPersistedEntries(store, target)
local tables = scopeTables(store)
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 store ECM_SpellColorStore
---@param target ECM_SpellColorKey|nil
---@return boolean removed
local function removeMatchingDiscoveredEntries(store, target)
if not target then
return false
end
local discoveredKeys = getDiscoveredKeys(store)
local removed = false
local nextIndex = 1
for index = 1, #discoveredKeys do
local key = discoveredKeys[index]
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
---------------------------------------------------------------------------
--- Gets the custom color for a spell by a normalized key object.
---@param key ECM_SpellColorKey|table|nil
---@return ECM_Color|nil
function SpellColorStore:GetColorByKey(key)
local normalized = normalizeKey(key)
if not normalized then
return nil
end
return storeGet(self, normalized:ToArray())
end
--- Extracts identifying values from a bar frame and returns a normalized key.
---@param frame ECM_BuffBarMixin
---@return ECM_SpellColorKey|nil
local function makeKeyFromBar(frame)
return makeKey(
frame.Bar and frame.Bar.Name and frame.Bar.Name.GetText and frame.Bar.Name:GetText(),
frame.cooldownInfo and frame.cooldownInfo.spellID,
frame.cooldownID,
FrameUtil.GetIconTextureFileID(frame)
)
end
--- Gets the custom color for a bar frame.
---@param frame ECM_BuffBarMixin
---@return ECM_Color|nil
function SpellColorStore:GetColorForBar(frame)
ns.DebugAssert(frame, "Expected bar frame")
if not (frame and frame.__ecmHooked) then
ns.Log("SpellColors", "GetColorForBar - invalid bar frame", {
frame = frame,
nameExists = frame and frame.Bar and type(frame.Bar.Name) == "table" and type(frame.Bar.Name.GetText) == "function",
iconExists = frame and type(frame.Icon) == "table" and type(frame.Icon.GetRegions) == "function",
})
return nil
end
return self:GetColorByKey(makeKeyFromBar(frame))
end
--- Returns deduplicated color entries for the current class/spec.
---@return { key: ECM_SpellColorKey, color: ECM_Color }[]
function SpellColorStore:GetAllColorEntries()
local cfg = config(self)
if not cfg then
return {}
end
local classSpecStores = getCurrentClassSpecStores(cfg)
if not classSpecStores then
return {}
end
local result = {}
local function maybeSanitizeOutputColor(value)
if hasLegacyColorMetadata(value) then
return sanitizeColorValue(value) or value
end
return value
end
local function candidateWins(row, tsValue, tierIndex)
if tsValue > (row._ts or 0) then
return true
end
if tsValue < (row._ts or 0) then
return false
end
return tierIndex < (row._tierIndex or math.huge)
end
for tierIndex, scopeKey in ipairs(KEY_DEFS) do
local keyType = KEY_TYPE_TO_STORE[scopeKey]
local storeTable = classSpecStores[scopeKey]
if storeTable then
for rawKey, entry in pairs(storeTable) do
if type(entry) == "table" and type(entry.value) == "table" then
local key = buildKeyFromEntry(entry, keyType, rawKey)
if key then
local rowTs = entryTs(entry)
local rowColor = maybeSanitizeOutputColor(entry.value)
local merged = false
for _, row in ipairs(result) do
if row.key:Matches(key) then
row.key = row.key:Merge(key) or row.key
if candidateWins(row, rowTs, tierIndex) then
row.color = rowColor
row._ts = rowTs
row._tierIndex = tierIndex
end
merged = true
break
end
end
if not merged then
result[#result + 1] = {
key = key,
color = rowColor,
_ts = rowTs,
_tierIndex = tierIndex,
}
end
end
end
end
end
end
-- Merge runtime-discovered keys so the UI shows all visible bars
-- without BuffBarsOptions reaching into BuffBars directly.
for _, dKey in ipairs(getDiscoveredKeys(self)) do
local merged = false
for _, row in ipairs(result) do
if row.key:Matches(dKey) then
row.key = row.key:Merge(dKey) or row.key
merged = true
break
end
end
if not merged then
result[#result + 1] = { key = dKey }
end
end
for _, row in ipairs(result) do
row._ts = nil
row._tierIndex = nil
end
return result
end
--- Sets a custom color for a spell by normalized key object.