-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCustomTrackers.lua
More file actions
2014 lines (1813 loc) · 75 KB
/
CustomTrackers.lua
File metadata and controls
2014 lines (1813 loc) · 75 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
--[[
SimpleUnitFrames – Custom Trackers
Draggable icon bars for tracking spells, items, trinkets, and consumables.
Adapted from QUI Custom Trackers by Grevin.
]]
local AceAddon = LibStub("AceAddon-3.0")
local addon = AceAddon and AceAddon:GetAddon("SimpleUnitFrames", true)
if not addon then return end
local LSM = LibStub("LibSharedMedia-3.0", true)
local LCG = LibStub("LibCustomGlow-1.0", true)
---------------------------------------------------------------------------
-- MODULE TABLE
---------------------------------------------------------------------------
local CT = {}
CT.activeBars = {}
CT.infoCache = {}
CT.autoLearnQueue = {}
CT.itemSpellIndex = {}
addon.CustomTrackers = CT
---------------------------------------------------------------------------
-- CONSTANTS
---------------------------------------------------------------------------
local BASE_CROP = 0.08
local MAX_TRACKER_ENTRIES_PER_BAR = 256
local HOUSING_INSTANCE_TYPES = {
["neighborhood"] = true,
["interior"] = true,
}
local function IsPlayerInInstance()
local _, instanceType = GetInstanceInfo()
if not instanceType or instanceType == "none" then return false end
if HOUSING_INSTANCE_TYPES[instanceType] then return false end
return true
end
---------------------------------------------------------------------------
-- DB HELPERS
---------------------------------------------------------------------------
local function GetDB()
if addon.db and addon.db.profile and addon.db.profile.customTrackers then
return addon.db.profile.customTrackers
end
return nil
end
local function GetAutoLearnConfig()
local db = GetDB()
if not db then return nil end
db.autoLearn = db.autoLearn or {}
local cfg = db.autoLearn
if cfg.enabled == nil then cfg.enabled = false end
if cfg.learnSpells == nil then cfg.learnSpells = true end
if cfg.learnItems == nil then cfg.learnItems = true end
if cfg.learnOnlyKnownSpells == nil then cfg.learnOnlyKnownSpells = false end
if cfg.excludeNPCSpells == nil then cfg.excludeNPCSpells = false end
if cfg.includeItemSpells == nil then cfg.includeItemSpells = true end
if cfg.targetBarID == nil then cfg.targetBarID = "" end
return cfg
end
---------------------------------------------------------------------------
-- FONT HELPERS
---------------------------------------------------------------------------
local function GetFont()
if LSM then
local name = addon.db and addon.db.profile and addon.db.profile.media and addon.db.profile.media.font
if name then
local path = LSM:Fetch("font", name)
if path then return path end
end
end
return STANDARD_TEXT_FONT
end
local function GetFontOutline()
return "OUTLINE"
end
---------------------------------------------------------------------------
-- INFO CACHE
---------------------------------------------------------------------------
local function GetCachedSpellInfo(spellID)
if not spellID then return nil end
local key = "spell_" .. spellID
if CT.infoCache[key] then return CT.infoCache[key] end
local info = C_Spell.GetSpellInfo(spellID)
if info then
CT.infoCache[key] = { name = info.name, icon = info.iconID, id = spellID, type = "spell" }
return CT.infoCache[key]
end
return nil
end
local function GetCachedItemInfo(itemID)
if not itemID then return nil end
local key = "item_" .. itemID
if CT.infoCache[key] then return CT.infoCache[key] end
local name, _, _, _, _, _, _, _, _, icon = C_Item.GetItemInfo(itemID)
if name then
CT.infoCache[key] = { name = name, icon = icon, id = itemID, type = "item" }
return CT.infoCache[key]
end
C_Item.RequestLoadItemDataByID(itemID)
return nil
end
---------------------------------------------------------------------------
-- COOLDOWN INFO
---------------------------------------------------------------------------
local function GetSpellCooldownInfo(spellID)
if not spellID then return 0, 0, false, nil, 0 end
local info = C_Spell.GetSpellCooldown(spellID)
if info then
return info.startTime, info.duration, info.isEnabled, info.isOnGCD, info.modRate or 0
end
return 0, 0, true, nil, 0
end
local function GetItemCooldownInfo(itemID)
if not itemID then return 0, 0, false end
local startTime, duration, enable = C_Item.GetItemCooldown(itemID)
return startTime or 0, duration or 0, enable ~= 0
end
local function GetItemStackCount(itemID, includeCharges)
if not itemID then return 0 end
local includeUses = includeCharges ~= false
local count = C_Item.GetItemCount(itemID, false, includeUses, true)
return count or 0
end
-- Cache for multi-charge spells
local knownChargeSpells = {}
local chargeSpellLastCast = {}
local function GetSpellChargeCount(spellID)
if not spellID then return 0, 1, 0, 0, 0, false end
local chargeInfo = C_Spell.GetSpellCharges(spellID)
if not chargeInfo then return 0, 1, 0, 0, 0, false end
local maxCharges = chargeInfo.maxCharges
if not maxCharges then return 0, 1, 0, 0, 0, false end
local ok, isSecret = pcall(function() return maxCharges ~= maxCharges end) -- NaN check (secret values)
-- Safer: just try to compare
local safeOk, safeResult = pcall(function() return maxCharges > 1 end)
if not safeOk then
local cached = knownChargeSpells[spellID]
if cached and cached > 1 then
return chargeInfo.currentCharges, cached,
chargeInfo.cooldownStartTime or 0,
chargeInfo.cooldownDuration or 0,
chargeInfo.chargeModRate or 0,
chargeInfo.isActive == true
end
return 0, 1, 0, 0, 0, false
end
if safeResult then
knownChargeSpells[spellID] = maxCharges
return chargeInfo.currentCharges or 0, maxCharges,
chargeInfo.cooldownStartTime or 0,
chargeInfo.cooldownDuration or 0,
chargeInfo.chargeModRate or 0,
chargeInfo.isActive == true
end
knownChargeSpells[spellID] = 1
return 0, 1, 0, 0, 0, false
end
local function GetSpellLossOfControlInfo(spellID)
if not spellID or not C_Spell or not C_Spell.GetSpellLossOfControlCooldownInfo then
return 0, 0, 0, false, false
end
local info = C_Spell.GetSpellLossOfControlCooldownInfo(spellID)
if not info then
return 0, 0, 0, false, false
end
return info.startTime or 0,
info.duration or 0,
info.modRate or 0,
info.isActive == true,
info.shouldReplaceNormalCooldown == true
end
local function IsCooldownFrameActive(cooldownFrame)
if not cooldownFrame then return false end
local ok, shown = pcall(cooldownFrame.IsShown, cooldownFrame)
return ok and shown == true
end
local function HasPositiveCooldownWindow(startTime, duration)
if not startTime or not duration then return false end
local ok, active = pcall(function()
return startTime > 0 and duration > 0
end)
return ok and active == true
end
---------------------------------------------------------------------------
-- ITEM HELPERS
---------------------------------------------------------------------------
local function IsEquipmentItem(itemID)
local classID = select(6, C_Item.GetItemInfoInstant(itemID))
if not classID then return false end
return classID == Enum.ItemClass.Armor or classID == Enum.ItemClass.Weapon
end
local function IsItemUsable(itemID, itemCount)
if IsEquipmentItem(itemID) then
return C_Item.IsEquippedItem(itemID)
end
return itemCount and itemCount > 0
end
local function IsSpellUsable(spellID)
local spellInfo = C_Spell.GetSpellInfo(spellID)
if not spellInfo then return false end
if IsSpellKnownOrOverridesKnown then
return IsSpellKnownOrOverridesKnown(spellID)
elseif IsPlayerSpell then
return IsPlayerSpell(spellID)
end
return IsSpellKnown(spellID)
end
local function IsSpellKnownByPlayer(spellID)
if not spellID or spellID <= 0 then return false end
if IsSpellKnownOrOverridesKnown then
return IsSpellKnownOrOverridesKnown(spellID)
elseif IsPlayerSpell then
return IsPlayerSpell(spellID)
end
return IsSpellKnown(spellID)
end
local function IsNPCSpell(spellID)
if not spellID or spellID <= 0 then return false end
local spellInfo = C_Spell.GetSpellInfo(spellID)
if not spellInfo then return true end
-- Check if spell has any useful info; if not, it might be NPC-only
-- NPC spells typically don't show in player skill books
if not IsSpellKnownByPlayer(spellID) then
-- Only consider it a true NPC spell if it has no learned status at all
if IsPlayerSpell then
return not IsPlayerSpell(spellID)
elseif IsSpellKnown then
return not IsSpellKnown(spellID)
end
end
return false
end
local function BuildBarLookup()
local db = GetDB()
local bars = (db and db.bars) or {}
local byID = {}
for i = 1, #bars do
local bar = bars[i]
if bar and bar.id then
byID[bar.id] = bar
end
end
return bars, byID
end
local function NormalizeEntryID(entryID)
local id = tonumber(entryID)
if id and id > 0 then
return math.floor(id + 0.5)
end
return entryID
end
local function EntryIDsEqual(leftID, rightID)
local left = NormalizeEntryID(leftID)
local right = NormalizeEntryID(rightID)
return left == right
end
local function SanitizeBarEntries(entries)
if type(entries) ~= "table" then
return {}, false, false
end
local beforeCount = #entries
if beforeCount == 0 then
return entries, false, false
end
local sanitized = {}
local seen = {}
local truncated = false
for i = 1, beforeCount do
local entry = entries[i]
local entryType = entry and entry.type
local normalizedID = entry and NormalizeEntryID(entry.id)
if (entryType == "spell" or entryType == "item") and normalizedID and normalizedID > 0 then
local key = entryType .. ":" .. tostring(normalizedID)
if not seen[key] then
seen[key] = true
entry.id = normalizedID
sanitized[#sanitized + 1] = entry
if #sanitized >= MAX_TRACKER_ENTRIES_PER_BAR then
if i < beforeCount then
truncated = true
end
break
end
end
end
end
local changed = (#sanitized ~= beforeCount) or truncated
if not changed then
return entries, false, false
end
return sanitized, true, truncated
end
function CT:IsEntryTracked(entryType, entryID, barID)
local targetID = NormalizeEntryID(entryID)
local bars, byID = BuildBarLookup()
if barID and byID[barID] then
local entries = byID[barID].entries or {}
for i = 1, #entries do
local entry = entries[i]
if entry and entry.type == entryType and EntryIDsEqual(entry.id, targetID) then
entry.id = NormalizeEntryID(entry.id)
return true
end
end
return false
end
for i = 1, #bars do
local entries = bars[i].entries or {}
for j = 1, #entries do
local entry = entries[j]
if entry and entry.type == entryType and EntryIDsEqual(entry.id, targetID) then
entry.id = NormalizeEntryID(entry.id)
return true
end
end
end
return false
end
function CT:GetAutoLearnTargetBarID()
local cfg = GetAutoLearnConfig()
if not cfg then return nil end
local bars, byID = BuildBarLookup()
if cfg.targetBarID and cfg.targetBarID ~= "" and byID[cfg.targetBarID] then
return cfg.targetBarID
end
return bars[1] and bars[1].id or nil
end
local function AddItemSpellMapping(itemSpellIndex, itemID)
if not itemID or itemID <= 0 then return end
local _, spellID = C_Item.GetItemSpell(itemID)
if not spellID or spellID <= 0 then return end
itemSpellIndex[spellID] = itemSpellIndex[spellID] or {}
itemSpellIndex[spellID][itemID] = true
end
function CT:RebuildItemSpellIndex()
wipe(self.itemSpellIndex)
-- Bags
if C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerItemID then
for bag = 0, (NUM_BAG_SLOTS or 4) do
local slots = C_Container.GetContainerNumSlots(bag) or 0
for slot = 1, slots do
local itemID = C_Container.GetContainerItemID(bag, slot)
if itemID then
AddItemSpellMapping(self.itemSpellIndex, itemID)
end
end
end
end
-- Equipped items
for slot = INVSLOT_FIRST_EQUIPPED or 1, INVSLOT_LAST_EQUIPPED or 19 do
local itemID = GetInventoryItemID("player", slot)
if itemID then
AddItemSpellMapping(self.itemSpellIndex, itemID)
end
end
end
function CT:ResolveItemIDForSpell(spellID)
if not spellID or spellID <= 0 then return nil end
local mapped = self.itemSpellIndex and self.itemSpellIndex[spellID]
if not mapped then return nil end
local bestItem
for itemID in pairs(mapped) do
if C_Item.IsEquippedItem(itemID) then
return itemID
end
if not bestItem then
bestItem = itemID
end
end
return bestItem
end
function CT:TryAutoLearnEntry(entryType, entryID, sourceLabel)
local cfg = GetAutoLearnConfig()
if not cfg or cfg.enabled ~= true then return false end
if entryType == "spell" and cfg.learnSpells ~= true then return false end
if entryType == "item" and cfg.learnItems ~= true then return false end
if not entryID or entryID <= 0 then return false end
-- Apply spell filters
if entryType == "spell" then
-- Filter: Only learn spells player already knows
if cfg.learnOnlyKnownSpells and not IsSpellKnownByPlayer(entryID) then
return false
end
-- Filter: Exclude NPC-only spells
if cfg.excludeNPCSpells and IsNPCSpell(entryID) then
return false
end
end
-- For item spells, check if we should include them
if entryType == "item" and not cfg.includeItemSpells then
return false
end
local barID = self:GetAutoLearnTargetBarID()
if not barID then return false end
if self:IsEntryTracked(entryType, entryID) then return false end
if InCombatLockdown() then
local key = tostring(entryType) .. ":" .. tostring(entryID)
self.autoLearnQueue[key] = {
type = entryType,
id = entryID,
source = sourceLabel,
barID = barID,
}
return false
end
local added = self:AddEntry(barID, entryType, entryID)
if added and addon and addon.Print then
local label = entryType == "spell" and "Spell" or "Item"
addon:Print(("SimpleUnitFrames: Auto-learned %s %d into '%s'."):format(label, entryID, tostring(barID)))
end
return added
end
function CT:FlushAutoLearnQueue()
if InCombatLockdown() then return end
for key, queued in pairs(self.autoLearnQueue) do
if queued and queued.type and queued.id then
self:TryAutoLearnEntry(queued.type, queued.id, queued.source)
end
self.autoLearnQueue[key] = nil
end
end
---------------------------------------------------------------------------
-- ACTIVE STATE DETECTION (casting / channeling / buff)
---------------------------------------------------------------------------
local function GetSpellCastInfo(spellID)
if not spellID then return false end
local _, _, _, startMS, endMS, _, _, _, castID = UnitCastingInfo("player")
if castID and castID == spellID then return true, startMS, endMS end
return false
end
local function GetSpellChannelInfo(spellID)
if not spellID then return false end
local _, _, _, startMS, endMS, _, _, _, chanID = UnitChannelInfo("player")
if chanID and chanID == spellID then return true, startMS, endMS end
return false
end
local function GetSpellBuffInfo(spellID)
if not spellID then return false end
if InCombatLockdown() then return false end
if C_UnitAuras and C_UnitAuras.GetPlayerAuraBySpellID then
local ok, aura = pcall(C_UnitAuras.GetPlayerAuraBySpellID, spellID)
if ok and aura then
return true, aura.expirationTime, aura.duration
end
end
return false
end
local function GetSpellActiveInfo(spellID)
if not spellID then return false end
local isCasting, castStart, castEnd = GetSpellCastInfo(spellID)
if isCasting and castStart and castEnd then
return true, castStart / 1000, (castEnd - castStart) / 1000, "cast"
end
local isChanneling, chanStart, chanEnd = GetSpellChannelInfo(spellID)
if isChanneling and chanStart and chanEnd then
return true, chanStart / 1000, (chanEnd - chanStart) / 1000, "channel"
end
local hasBuff, expiration, buffDuration = GetSpellBuffInfo(spellID)
if hasBuff and expiration and buffDuration then
return true, expiration - buffDuration, buffDuration, "buff"
end
return false
end
local function GetItemActiveInfo(itemID)
if not itemID then return false end
local itemSpellID = select(2, C_Item.GetItemSpell(itemID))
if itemSpellID then return GetSpellActiveInfo(itemSpellID) end
return false
end
---------------------------------------------------------------------------
-- GLOW (LibCustomGlow)
---------------------------------------------------------------------------
local function StartActiveGlow(icon, config)
if not icon or not LCG then return end
if icon._activeGlowShown then return end
if config and config.activeGlowEnabled == false then return end
local w, h = icon:GetSize()
if not w or not h or w < 10 or h < 10 then return end
local glowType = (config and config.activeGlowType) or "Pixel Glow"
local color = (config and config.activeGlowColor) or { 1, 0.85, 0.3, 1 }
local lines = (config and config.activeGlowLines) or 8
local frequency = (config and config.activeGlowFrequency) or 0.25
local thickness = (config and config.activeGlowThickness) or 2
local scale = (config and config.activeGlowScale) or 1.0
if glowType == "Pixel Glow" then
LCG.PixelGlow_Start(icon, color, lines, frequency, nil, thickness, 0, 0, true, "_SUFActiveGlow")
icon._activeGlowShown = true
icon._activeGlowType = glowType
elseif glowType == "Autocast Shine" then
LCG.AutoCastGlow_Start(icon, color, lines, frequency, scale, 0, 0, "_SUFActiveGlow")
icon._activeGlowShown = true
icon._activeGlowType = glowType
end
end
local function StopActiveGlow(icon)
if not icon or not LCG then return end
if not icon._activeGlowShown then return end
local glowType = icon._activeGlowType or "Pixel Glow"
if glowType == "Pixel Glow" then
pcall(LCG.PixelGlow_Stop, icon, "_SUFActiveGlow")
elseif glowType == "Autocast Shine" then
pcall(LCG.AutoCastGlow_Stop, icon, "_SUFActiveGlow")
end
icon._activeGlowShown = nil
icon._activeGlowType = nil
end
---------------------------------------------------------------------------
-- POSITIONING
---------------------------------------------------------------------------
local function FindSUFFrame(unit)
local nameMap = {
player = "SUF_Player",
target = "SUF_Target",
focus = "SUF_Focus",
pet = "SUF_Pet",
}
local name = nameMap[unit]
return name and _G[name] or nil
end
local function PositionBar(bar)
if not bar or not bar.config then return end
local config = bar.config
-- Locked to player frame
if config.lockedToPlayer then
local playerFrame = FindSUFFrame("player")
if playerFrame then
bar:SetParent(playerFrame)
bar:SetFrameLevel(playerFrame:GetFrameLevel() + 10)
local lockPos = config.lockPosition or "bottomcenter"
local bs = config.borderSize or 2
local ux = config.offsetX or 0
local uy = config.offsetY or 0
bar:ClearAllPoints()
if lockPos == "topleft" then
bar:SetPoint("BOTTOMLEFT", playerFrame, "TOPLEFT", bs + ux, bs + uy)
elseif lockPos == "topcenter" then
bar:SetPoint("BOTTOM", playerFrame, "TOP", ux, bs + uy)
elseif lockPos == "topright" then
bar:SetPoint("BOTTOMRIGHT", playerFrame, "TOPRIGHT", -bs + ux, bs + uy)
elseif lockPos == "bottomleft" then
bar:SetPoint("TOPLEFT", playerFrame, "BOTTOMLEFT", bs + ux, -bs + uy)
elseif lockPos == "bottomcenter" then
bar:SetPoint("TOP", playerFrame, "BOTTOM", ux, -bs + uy)
elseif lockPos == "bottomright" then
bar:SetPoint("TOPRIGHT", playerFrame, "BOTTOMRIGHT", -bs + ux, -bs + uy)
else
bar:SetPoint("TOP", playerFrame, "BOTTOM", ux, -bs + uy)
end
return
end
end
-- Locked to target frame
if config.lockedToTarget then
local targetFrame = FindSUFFrame("target")
if targetFrame then
bar:SetParent(targetFrame)
bar:SetFrameLevel(targetFrame:GetFrameLevel() + 10)
local lockPos = config.targetLockPosition or "bottomcenter"
local bs = config.borderSize or 2
local ux = config.offsetX or 0
local uy = config.offsetY or 0
bar:ClearAllPoints()
if lockPos == "topleft" then
bar:SetPoint("BOTTOMLEFT", targetFrame, "TOPLEFT", bs + ux, bs + uy)
elseif lockPos == "topcenter" then
bar:SetPoint("BOTTOM", targetFrame, "TOP", ux, bs + uy)
elseif lockPos == "topright" then
bar:SetPoint("BOTTOMRIGHT", targetFrame, "TOPRIGHT", -bs + ux, bs + uy)
elseif lockPos == "bottomleft" then
bar:SetPoint("TOPLEFT", targetFrame, "BOTTOMLEFT", bs + ux, -bs + uy)
elseif lockPos == "bottomcenter" then
bar:SetPoint("TOP", targetFrame, "BOTTOM", ux, -bs + uy)
elseif lockPos == "bottomright" then
bar:SetPoint("TOPRIGHT", targetFrame, "BOTTOMRIGHT", -bs + ux, -bs + uy)
else
bar:SetPoint("TOP", targetFrame, "BOTTOM", ux, -bs + uy)
end
return
end
end
-- Free-floating on UIParent
bar:SetParent(UIParent)
bar:SetMovable(true)
bar:EnableMouse(true)
bar:RegisterForDrag("LeftButton")
bar:SetClampedToScreen(true)
local offsetX = config.offsetX or 0
local offsetY = config.offsetY or -300
local growDir = config.growDirection or "RIGHT"
bar:ClearAllPoints()
if growDir == "RIGHT" then
bar:SetPoint("LEFT", UIParent, "CENTER", offsetX, offsetY)
elseif growDir == "LEFT" then
bar:SetPoint("RIGHT", UIParent, "CENTER", offsetX, offsetY)
elseif growDir == "DOWN" then
bar:SetPoint("TOP", UIParent, "CENTER", offsetX, offsetY)
elseif growDir == "UP" then
bar:SetPoint("BOTTOM", UIParent, "CENTER", offsetX, offsetY)
else
bar:SetPoint("CENTER", UIParent, "CENTER", offsetX, offsetY)
end
end
---------------------------------------------------------------------------
-- ICON CREATION
---------------------------------------------------------------------------
local function CreateTrackerIcon(parent, clickable)
local icon = CreateFrame("Frame", nil, parent)
icon.__sufCustomTrackerIcon = true
icon:SetSize(36, 36)
-- Border
icon.border = icon:CreateTexture(nil, "BACKGROUND", nil, -8)
icon.border:SetColorTexture(0, 0, 0, 1)
-- Icon texture
icon.tex = icon:CreateTexture(nil, "ARTWORK")
icon.tex:SetAllPoints()
-- Cooldown frame (Blizzard's built-in – handles secret values internally)
icon.cooldown = CreateFrame("Cooldown", nil, icon, "CooldownFrameTemplate")
icon.cooldown:SetAllPoints()
icon.cooldown:SetDrawSwipe(false)
icon.cooldown:SetDrawEdge(false)
icon.cooldown:SetHideCountdownNumbers(false)
icon.cooldown:EnableMouse(false)
if icon.cooldown.SetDrawBling then icon.cooldown:SetDrawBling(false) end
-- Duration text (custom, hidden by default – we use Blizzard countdown)
icon.durationText = icon:CreateFontString(nil, "OVERLAY")
icon.durationText:SetFont(GetFont(), 14, GetFontOutline())
icon.durationText:Hide()
-- Stack/charge text
icon.stackText = icon:CreateFontString(nil, "OVERLAY")
icon.stackText:SetFont(GetFont(), 12, GetFontOutline())
icon.stackText:Hide()
-- Tooltip
local function ShowTooltip(iconFrame)
if iconFrame:GetAlpha() == 0 then return end
if iconFrame.entry then
GameTooltip_SetDefaultAnchor(GameTooltip, iconFrame)
if iconFrame.entry.type == "spell" then
GameTooltip:SetSpellByID(iconFrame.entry.id)
elseif iconFrame.entry.type == "item" then
pcall(GameTooltip.SetItemByID, GameTooltip, iconFrame.entry.id)
end
GameTooltip:Show()
end
end
icon:SetScript("OnEnter", function(self) ShowTooltip(self) end)
icon:SetScript("OnLeave", function() GameTooltip:Hide() end)
-- Forward drag to parent bar
icon:RegisterForDrag("LeftButton")
icon:SetScript("OnDragStart", function(self)
local bar = self:GetParent()
if bar and bar.config and not bar.config.locked
and not bar.config.lockedToPlayer
and not bar.config.lockedToTarget then
bar:StartMoving()
end
end)
icon:SetScript("OnDragStop", function(self)
local bar = self:GetParent()
if bar then
bar:StopMovingOrSizing()
local handler = bar:GetScript("OnDragStop")
if handler then handler(bar) end
end
end)
-- Clickable secure button (only when requested)
if clickable then
icon.clickButton = CreateFrame("Button", nil, icon, "SecureActionButtonTemplate")
icon.clickButton:SetAllPoints()
icon.clickButton:RegisterForClicks("AnyUp", "AnyDown")
icon.clickButton:EnableMouse(true)
icon.clickButton:Hide()
icon.clickButton:RegisterForDrag("LeftButton")
icon.clickButton:SetScript("OnDragStart", function(self)
local bar = self:GetParent():GetParent()
if bar and bar.config and not bar.config.locked
and not bar.config.lockedToPlayer
and not bar.config.lockedToTarget then
bar:StartMoving()
end
end)
icon.clickButton:SetScript("OnDragStop", function(self)
local bar = self:GetParent():GetParent()
if bar then
bar:StopMovingOrSizing()
local handler = bar:GetScript("OnDragStop")
if handler then handler(bar) end
end
end)
icon.clickButton:SetScript("OnEnter", function(self) ShowTooltip(self:GetParent()) end)
icon.clickButton:SetScript("OnLeave", function() GameTooltip:Hide() end)
end
return icon
end
---------------------------------------------------------------------------
-- SECURE BUTTON ATTRIBUTES
---------------------------------------------------------------------------
local function UpdateIconSecureAttributes(icon, entry, config)
if not icon or not icon.clickButton then return end
if InCombatLockdown() then
icon._pendingSecureUpdate = true
return
end
local function Clear()
icon.clickButton:SetAttribute("type", nil)
icon.clickButton:SetAttribute("spell", nil)
icon.clickButton:SetAttribute("item", nil)
end
if not config or not config.clickableIcons then
Clear(); icon.clickButton:Hide(); return
end
if not entry then
Clear(); icon.clickButton:Hide(); return
end
if entry.type == "spell" then
local info = GetCachedSpellInfo(entry.id)
if info and info.name then
icon.clickButton:SetAttribute("type", "spell")
icon.clickButton:SetAttribute("spell", info.name)
icon.clickButton:Show()
else
Clear(); icon.clickButton:Hide()
end
elseif entry.type == "item" then
local info = GetCachedItemInfo(entry.id)
if info and info.name then
icon.clickButton:SetAttribute("type", "item")
icon.clickButton:SetAttribute("item", info.name)
icon.clickButton:Show()
else
Clear(); icon.clickButton:Hide()
end
else
Clear(); icon.clickButton:Hide()
end
icon._pendingSecureUpdate = nil
end
---------------------------------------------------------------------------
-- ICON STYLING
---------------------------------------------------------------------------
local function StyleTrackerIcon(icon, config)
if not icon or not config then return end
local aspectRatio = config.aspectRatioCrop or 1.0
local width = config.iconSize or 36
local height = width / aspectRatio
icon:SetSize(width, height)
-- Border
local bs = config.borderSize or 2
if bs > 0 then
icon.border:Show()
icon.border:ClearAllPoints()
icon.border:SetPoint("TOPLEFT", -bs, bs)
icon.border:SetPoint("BOTTOMRIGHT", bs, -bs)
else
icon.border:Hide()
end
-- TexCoords with zoom + aspect crop
local zoom = config.zoom or 0
local left = BASE_CROP + zoom
local right = 1 - BASE_CROP - zoom
local top = BASE_CROP + zoom
local bottom = 1 - BASE_CROP - zoom
if aspectRatio > 1.0 then
local cropAmount = 1.0 - (1.0 / aspectRatio)
local availH = bottom - top
local offset = (cropAmount * availH) / 2.0
top = top + offset
bottom = bottom - offset
end
icon.tex:SetTexCoord(left, right, top, bottom)
-- Stack text style (support per-bar stackFont)
local function ResolveBarlFont(name)
if name and LSM then
local path = LSM:Fetch("font", name)
if path then return path end
end
return GetFont()
end
local stackFontPath = ResolveBarlFont(config.stackFont)
local stackFontSize = config.stackSize or 12
local stackColor = config.stackColor or { 1, 1, 1, 1 }
icon.stackText:SetFont(stackFontPath, stackFontSize, GetFontOutline())
icon.stackText:SetTextColor(stackColor[1], stackColor[2], stackColor[3], stackColor[4] or 1)
icon.stackText:ClearAllPoints()
icon.stackText:SetPoint(
config.stackAnchor or "BOTTOMRIGHT",
icon,
config.stackAnchor or "BOTTOMRIGHT",
config.stackOffsetX or -2,
config.stackOffsetY or 2
)
-- Duration text style (also style Blizzard's countdown)
local dColor = config.durationColor or { 1, 1, 1, 1 }
local dSize = config.durationSize or 14
local dFontPath = ResolveBarlFont(config.durationFont)
-- Style custom durationText fontstring (used when we render our own timer)
icon.durationText:SetFont(dFontPath, dSize, GetFontOutline())
icon.durationText:SetTextColor(dColor[1], dColor[2], dColor[3], dColor[4] or 1)
icon.durationText:ClearAllPoints()
icon.durationText:SetPoint(
config.durationAnchor or "CENTER",
icon,
config.durationAnchor or "CENTER",
config.durationOffsetX or 0,
config.durationOffsetY or 0
)
-- Also attempt to style Blizzard's built-in countdown text via GetRegions
if icon.cooldown then
local cooldown = icon.cooldown
local ok, regions = pcall(function() return { cooldown:GetRegions() } end)
if ok and regions then
for _, region in ipairs(regions) do
if region and region.GetObjectType
and region:GetObjectType() == "FontString" then
region:SetFont(dFontPath, dSize, GetFontOutline())
region:SetTextColor(dColor[1], dColor[2], dColor[3], dColor[4] or 1)
end
end
end
end
end
---------------------------------------------------------------------------
-- ICON LAYOUT
---------------------------------------------------------------------------
local function LayoutBarIcons(bar)
if not bar or not bar.icons then return end
local config = bar.config
local growDir = config.growDirection or "RIGHT"
local spacing = config.spacing or 4
local aspectRatio = config.aspectRatioCrop or 1.0
local iconWidth = config.iconSize or 36
local iconHeight = iconWidth / aspectRatio
local numIcons = #bar.icons
for _, icon in ipairs(bar.icons) do icon:ClearAllPoints() end
for i, icon in ipairs(bar.icons) do
local offset = (i - 1) * (iconWidth + spacing)
if growDir == "RIGHT" then
icon:SetPoint("LEFT", bar, "LEFT", offset, 0)
elseif growDir == "LEFT" then
icon:SetPoint("RIGHT", bar, "RIGHT", -offset, 0)
elseif growDir == "DOWN" then
offset = (i - 1) * (iconHeight + spacing)
icon:SetPoint("TOP", bar, "TOP", 0, -offset)
elseif growDir == "UP" then
offset = (i - 1) * (iconHeight + spacing)
icon:SetPoint("BOTTOM", bar, "BOTTOM", 0, offset)
elseif growDir == "CENTER" then
local totalW = numIcons * iconWidth + (numIcons - 1) * spacing
local startX = -totalW / 2 + iconWidth / 2
icon:SetPoint("CENTER", bar, "CENTER", startX + (i - 1) * (iconWidth + spacing), 0)
elseif growDir == "CENTER_VERTICAL" then
local totalH = numIcons * iconHeight + (numIcons - 1) * spacing
local startY = totalH / 2 - iconHeight / 2
icon:SetPoint("CENTER", bar, "CENTER", 0, startY - (i - 1) * (iconHeight + spacing))
end
icon:Show()
end
if numIcons == 0 then
bar:SetSize(1, 1)
return
end
if growDir == "RIGHT" or growDir == "LEFT" or growDir == "CENTER" then
bar:SetSize(numIcons * iconWidth + (numIcons - 1) * spacing, iconHeight)
else
bar:SetSize(iconWidth, numIcons * iconHeight + (numIcons - 1) * spacing)
end
end
local function LayoutVisibleIcons(bar)
if not bar or not bar.icons then return end
local config = bar.config
local growDir = config.growDirection or "RIGHT"
local spacing = config.spacing or 4
local aspectRatio = config.aspectRatioCrop or 1.0
local iconWidth = config.iconSize or 36
local iconHeight = iconWidth / aspectRatio
local visible = {}
for _, icon in ipairs(bar.icons) do
if icon.isVisible ~= false then visible[#visible + 1] = icon end
end
for _, icon in ipairs(bar.icons) do icon:ClearAllPoints() end
local n = #visible
for i, icon in ipairs(visible) do
local offset = (i - 1) * (iconWidth + spacing)
if growDir == "RIGHT" then
icon:SetPoint("LEFT", bar, "LEFT", offset, 0)
elseif growDir == "LEFT" then
icon:SetPoint("RIGHT", bar, "RIGHT", -offset, 0)
elseif growDir == "DOWN" then
offset = (i - 1) * (iconHeight + spacing)
icon:SetPoint("TOP", bar, "TOP", 0, -offset)
elseif growDir == "UP" then
offset = (i - 1) * (iconHeight + spacing)
icon:SetPoint("BOTTOM", bar, "BOTTOM", 0, offset)
elseif growDir == "CENTER" then
local totalW = n * iconWidth + (n - 1) * spacing
local startX = -totalW / 2 + iconWidth / 2
icon:SetPoint("CENTER", bar, "CENTER", startX + (i - 1) * (iconWidth + spacing), 0)
elseif growDir == "CENTER_VERTICAL" then
local totalH = n * iconHeight + (n - 1) * spacing
local startY = totalH / 2 - iconHeight / 2
icon:SetPoint("CENTER", bar, "CENTER", 0, startY - (i - 1) * (iconHeight + spacing))
end
end
if n == 0 then bar:SetSize(1, 1); return end
if growDir == "RIGHT" or growDir == "LEFT" or growDir == "CENTER" then
bar:SetSize(n * iconWidth + (n - 1) * spacing, iconHeight)