forked from EllesmereGaming/EllesmereUI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEllesmereUI_Profiles.lua
More file actions
2071 lines (1933 loc) · 101 KB
/
EllesmereUI_Profiles.lua
File metadata and controls
2071 lines (1933 loc) · 101 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
-------------------------------------------------------------------------------
-- EllesmereUI_Profiles.lua
--
-- Global profile system: import/export, presets, spec assignment.
-- Handles serialization (LibDeflate + custom serializer) and profile
-- management across all EllesmereUI addons.
--
-- Load order (via TOC):
-- 1. Libs/LibDeflate.lua
-- 2. EllesmereUI_Lite.lua
-- 3. EllesmereUI.lua
-- 4. EllesmereUI_Widgets.lua
-- 5. EllesmereUI_Presets.lua
-- 6. EllesmereUI_Profiles.lua -- THIS FILE
-------------------------------------------------------------------------------
local EllesmereUI = _G.EllesmereUI
-------------------------------------------------------------------------------
-- LibDeflate reference (loaded before us via TOC)
-- LibDeflate registers via LibStub, not as a global, so use LibStub to get it.
-------------------------------------------------------------------------------
local LibDeflate = LibStub and LibStub("LibDeflate", true) or _G.LibDeflate
-------------------------------------------------------------------------------
-- Reload popup: uses Blizzard StaticPopup so the button click is a hardware
-- event and ReloadUI() is not blocked as a protected function call.
-------------------------------------------------------------------------------
StaticPopupDialogs["EUI_PROFILE_RELOAD"] = {
text = "EllesmereUI Profile switched. Reload UI to apply?",
button1 = "Reload Now",
button2 = "Later",
OnAccept = function() ReloadUI() end,
timeout = 0,
whileDead = true,
hideOnEscape = true,
preferredIndex = 3,
}
-------------------------------------------------------------------------------
-- Addon registry: display-order list of all managed addons.
-- Each entry: { folder, display, svName }
-- folder = addon folder name (matches _dbRegistry key)
-- display = human-readable name for the Profiles UI
-- svName = SavedVariables name (e.g. "EllesmereUINameplatesDB")
--
-- All addons use _dbRegistry for profile access. Order matters for UI display.
-------------------------------------------------------------------------------
local ADDON_DB_MAP = {
{ folder = "EllesmereUINameplates", display = "Nameplates", svName = "EllesmereUINameplatesDB" },
{ folder = "EllesmereUIActionBars", display = "Action Bars", svName = "EllesmereUIActionBarsDB" },
{ folder = "EllesmereUIUnitFrames", display = "Unit Frames", svName = "EllesmereUIUnitFramesDB" },
{ folder = "EllesmereUICooldownManager", display = "Cooldown Manager", svName = "EllesmereUICooldownManagerDB" },
{ folder = "EllesmereUIResourceBars", display = "Resource Bars", svName = "EllesmereUIResourceBarsDB" },
{ folder = "EllesmereUIAuraBuffReminders", display = "AuraBuff Reminders", svName = "EllesmereUIAuraBuffRemindersDB" },
{ folder = "EllesmereUIBasics", display = "Basics", svName = "EllesmereUIBasicsDB" },
}
EllesmereUI._ADDON_DB_MAP = ADDON_DB_MAP
-------------------------------------------------------------------------------
-- Serializer: Lua table <-> string (no AceSerializer dependency)
-- Handles: string, number, boolean, nil, table (nested), color tables
-------------------------------------------------------------------------------
local Serializer = {}
local function SerializeValue(v, parts)
local t = type(v)
if t == "string" then
parts[#parts + 1] = "s"
-- Length-prefixed to avoid delimiter issues
parts[#parts + 1] = #v
parts[#parts + 1] = ":"
parts[#parts + 1] = v
elseif t == "number" then
parts[#parts + 1] = "n"
parts[#parts + 1] = tostring(v)
parts[#parts + 1] = ";"
elseif t == "boolean" then
parts[#parts + 1] = v and "T" or "F"
elseif t == "nil" then
parts[#parts + 1] = "N"
elseif t == "table" then
parts[#parts + 1] = "{"
-- Serialize array part first (integer keys 1..n)
local n = #v
for i = 1, n do
SerializeValue(v[i], parts)
end
-- Then hash part (non-integer keys, or integer keys > n)
for k, val in pairs(v) do
local kt = type(k)
if kt == "number" and k >= 1 and k <= n and k == math.floor(k) then
-- Already serialized in array part
else
parts[#parts + 1] = "K"
SerializeValue(k, parts)
SerializeValue(val, parts)
end
end
parts[#parts + 1] = "}"
end
end
function Serializer.Serialize(tbl)
local parts = {}
SerializeValue(tbl, parts)
return table.concat(parts)
end
-- Deserializer
local function DeserializeValue(str, pos)
local tag = str:sub(pos, pos)
if tag == "s" then
-- Find the colon after the length
local colonPos = str:find(":", pos + 1, true)
if not colonPos then return nil, pos end
local len = tonumber(str:sub(pos + 1, colonPos - 1))
if not len then return nil, pos end
local val = str:sub(colonPos + 1, colonPos + len)
return val, colonPos + len + 1
elseif tag == "n" then
local semi = str:find(";", pos + 1, true)
if not semi then return nil, pos end
return tonumber(str:sub(pos + 1, semi - 1)), semi + 1
elseif tag == "T" then
return true, pos + 1
elseif tag == "F" then
return false, pos + 1
elseif tag == "N" then
return nil, pos + 1
elseif tag == "{" then
local tbl = {}
local idx = 1
local p = pos + 1
while p <= #str do
local c = str:sub(p, p)
if c == "}" then
return tbl, p + 1
elseif c == "K" then
-- Key-value pair
local key, val
key, p = DeserializeValue(str, p + 1)
val, p = DeserializeValue(str, p)
if key ~= nil then
tbl[key] = val
end
else
-- Array element
local val
val, p = DeserializeValue(str, p)
tbl[idx] = val
idx = idx + 1
end
end
return tbl, p
end
return nil, pos + 1
end
function Serializer.Deserialize(str)
if not str or #str == 0 then return nil end
local val, _ = DeserializeValue(str, 1)
return val
end
EllesmereUI._Serializer = Serializer
-------------------------------------------------------------------------------
-- Deep copy utility
-------------------------------------------------------------------------------
local function DeepCopy(src, seen)
if type(src) ~= "table" then return src end
if seen and seen[src] then return seen[src] end
if not seen then seen = {} end
local copy = {}
seen[src] = copy
for k, v in pairs(src) do
-- Skip frame references and other userdata that can't be serialized
if type(v) ~= "userdata" and type(v) ~= "function" then
copy[k] = DeepCopy(v, seen)
end
end
return copy
end
local function DeepMerge(dst, src)
for k, v in pairs(src) do
if type(v) == "table" and type(dst[k]) == "table" then
DeepMerge(dst[k], v)
else
dst[k] = DeepCopy(v)
end
end
end
EllesmereUI._DeepCopy = DeepCopy
-------------------------------------------------------------------------------
-- Profile DB helpers
-- Profiles are stored in EllesmereUIDB.profiles = { [name] = profileData }
-- profileData = {
-- addons = { [folderName] = <snapshot of that addon's profile table> },
-- fonts = <snapshot of EllesmereUIDB.fonts>,
-- customColors = <snapshot of EllesmereUIDB.customColors>,
-- }
-- EllesmereUIDB.activeProfile = "Default" (name of active profile)
-- EllesmereUIDB.profileOrder = { "Default", ... }
-- EllesmereUIDB.specProfiles = { [specID] = "profileName" }
-------------------------------------------------------------------------------
local function GetProfilesDB()
if not EllesmereUIDB then EllesmereUIDB = {} end
if not EllesmereUIDB.profiles then EllesmereUIDB.profiles = {} end
if not EllesmereUIDB.profileOrder then EllesmereUIDB.profileOrder = {} end
if not EllesmereUIDB.specProfiles then EllesmereUIDB.specProfiles = {} end
return EllesmereUIDB
end
EllesmereUI.GetProfilesDB = GetProfilesDB
-------------------------------------------------------------------------------
-- Anchor offset format conversion
--
-- Anchor offsets were originally stored relative to the target's center
-- (format version 0/nil). The current system stores them relative to
-- stable edges (format version 1):
-- TOP/BOTTOM: offsetX relative to target LEFT edge
-- LEFT/RIGHT: offsetY relative to target TOP edge
--
--- Check if an addon is loaded
local function IsAddonLoaded(name)
if C_AddOns and C_AddOns.IsAddOnLoaded then return C_AddOns.IsAddOnLoaded(name) end
if _G.IsAddOnLoaded then return _G.IsAddOnLoaded(name) end
return false
end
--- Re-point all db.profile references to the given profile name.
--- Called when switching profiles so addons see the new data immediately.
local function RepointAllDBs(profileName)
if not EllesmereUIDB.profiles then EllesmereUIDB.profiles = {} end
if type(EllesmereUIDB.profiles[profileName]) ~= "table" then
EllesmereUIDB.profiles[profileName] = {}
end
local profileData = EllesmereUIDB.profiles[profileName]
if not profileData.addons then profileData.addons = {} end
local registry = EllesmereUI.Lite and EllesmereUI.Lite._dbRegistry
if not registry then return end
for _, db in ipairs(registry) do
local folder = db.folder
if folder then
if type(profileData.addons[folder]) ~= "table" then
profileData.addons[folder] = {}
end
db.profile = profileData.addons[folder]
db._profileName = profileName
-- Re-merge defaults so new profile has all keys
if db._profileDefaults then
EllesmereUI.Lite.DeepMergeDefaults(db.profile, db._profileDefaults)
end
end
end
-- Restore unlock layout from the profile.
-- If the profile has no unlockLayout yet (e.g. created before this key
-- existed), leave the live unlock data untouched so the current
-- positions are preserved. Only restore when the profile explicitly
-- contains layout data from a previous save.
local ul = profileData.unlockLayout
if ul then
EllesmereUIDB.unlockAnchors = DeepCopy(ul.anchors or {})
EllesmereUIDB.unlockWidthMatch = DeepCopy(ul.widthMatch or {})
EllesmereUIDB.unlockHeightMatch = DeepCopy(ul.heightMatch or {})
EllesmereUIDB.phantomBounds = DeepCopy(ul.phantomBounds or {})
end
-- Seed castbar anchor defaults if the profile predates them.
-- These follow the same per-profile unlockLayout system as all
-- other elements — this just ensures old profiles get the defaults.
do
local anchors = EllesmereUIDB.unlockAnchors
local wMatch = EllesmereUIDB.unlockWidthMatch
if anchors and wMatch then
local CB_DEFAULTS = {
{ cb = "playerCastbar", parent = "player" },
{ cb = "targetCastbar", parent = "target" },
{ cb = "focusCastbar", parent = "focus" },
}
for _, def in ipairs(CB_DEFAULTS) do
if not anchors[def.cb] then
anchors[def.cb] = { target = def.parent, side = "BOTTOM" }
end
if not wMatch[def.cb] then
wMatch[def.cb] = def.parent
end
end
end
end
-- Restore fonts and custom colors from the profile
if profileData.fonts then
local fontsDB = EllesmereUI.GetFontsDB()
for k in pairs(fontsDB) do fontsDB[k] = nil end
for k, v in pairs(profileData.fonts) do fontsDB[k] = DeepCopy(v) end
if fontsDB.global == nil then fontsDB.global = "Expressway" end
if fontsDB.outlineMode == nil then fontsDB.outlineMode = "shadow" end
end
if profileData.customColors then
local colorsDB = EllesmereUI.GetCustomColorsDB()
for k in pairs(colorsDB) do colorsDB[k] = nil end
for k, v in pairs(profileData.customColors) do colorsDB[k] = DeepCopy(v) end
end
end
-------------------------------------------------------------------------------
-- ResolveSpecProfile
--
-- Single authoritative function that resolves the current spec's target
-- profile name. Used by both PreSeedSpecProfile (before OnEnable) and the
-- runtime spec event handler.
--
-- Resolution order:
-- 1. Cached spec from lastSpecByChar (reliable across sessions)
-- 2. Live GetSpecialization() API (available after ADDON_LOADED for
-- returning characters, may be nil for brand-new characters)
--
-- Returns: targetProfileName, resolvedSpecID, charKey -- or nil if no
-- spec assignment exists or spec cannot be resolved yet.
-------------------------------------------------------------------------------
local function ResolveSpecProfile()
if not EllesmereUIDB then return nil end
local specProfiles = EllesmereUIDB.specProfiles
if not specProfiles or not next(specProfiles) then return nil end
local charKey = UnitName("player") .. " - " .. GetRealmName()
if not EllesmereUIDB.lastSpecByChar then
EllesmereUIDB.lastSpecByChar = {}
end
-- Prefer cached spec from last session (always reliable)
local resolvedSpecID = EllesmereUIDB.lastSpecByChar[charKey]
-- Fall back to live API if no cached value
if not resolvedSpecID then
local specIdx = GetSpecialization and GetSpecialization()
if specIdx and specIdx > 0 then
local liveSpecID = GetSpecializationInfo(specIdx)
if liveSpecID then
resolvedSpecID = liveSpecID
EllesmereUIDB.lastSpecByChar[charKey] = resolvedSpecID
end
end
end
if not resolvedSpecID then return nil end
local targetProfile = specProfiles[resolvedSpecID]
if not targetProfile then return nil end
local profiles = EllesmereUIDB.profiles
if not profiles or not profiles[targetProfile] then return nil end
return targetProfile, resolvedSpecID, charKey
end
-------------------------------------------------------------------------------
-- Spec profile pre-seed
--
-- Runs once just before child addon OnEnable calls, after all OnInitialize
-- calls have completed (so all NewDB calls have run).
-- At this point the spec API is available, so we can resolve the current
-- spec and re-point all db.profile references to the correct profile table
-- in the central store before any addon builds its UI.
--
-- This is the sole pre-OnEnable resolution point. NewDB reads activeProfile
-- as-is (defaults to "Default" or whatever was saved from last session).
-------------------------------------------------------------------------------
--- Called by EllesmereUI_Lite just before child addon OnEnable calls fire.
--- Uses ResolveSpecProfile() to determine the correct profile, then
--- re-points all db.profile references via RepointAllDBs.
function EllesmereUI.PreSeedSpecProfile()
local targetProfile, resolvedSpecID = ResolveSpecProfile()
if not targetProfile then
-- No spec assignment resolved; lock auto-save if spec profiles exist
if EllesmereUIDB and EllesmereUIDB.specProfiles and next(EllesmereUIDB.specProfiles) then
EllesmereUI._profileSaveLocked = true
end
return
end
EllesmereUIDB.activeProfile = targetProfile
RepointAllDBs(targetProfile)
EllesmereUI._preSeedComplete = true
end
--- Get the live profile table for an addon.
--- All addons use _dbRegistry (which points into
--- EllesmereUIDB.profiles[active].addons[folder]).
local function GetAddonProfile(entry)
if EllesmereUI.Lite and EllesmereUI.Lite._dbRegistry then
for _, db in ipairs(EllesmereUI.Lite._dbRegistry) do
if db.folder == entry.folder then
return db.profile
end
end
end
return nil
end
--- Snapshot the current state of all loaded addons into a profile data table
function EllesmereUI.SnapshotAllAddons()
local data = { addons = {} }
for _, entry in ipairs(ADDON_DB_MAP) do
if IsAddonLoaded(entry.folder) then
local profile = GetAddonProfile(entry)
if profile then
data.addons[entry.folder] = DeepCopy(profile)
end
end
end
-- Include global font and color settings
data.fonts = DeepCopy(EllesmereUI.GetFontsDB())
local cc = EllesmereUI.GetCustomColorsDB()
data.customColors = DeepCopy(cc)
-- Include unlock mode layout data (anchors, size matches)
if EllesmereUIDB then
data.unlockLayout = {
anchors = DeepCopy(EllesmereUIDB.unlockAnchors or {}),
widthMatch = DeepCopy(EllesmereUIDB.unlockWidthMatch or {}),
heightMatch = DeepCopy(EllesmereUIDB.unlockHeightMatch or {}),
phantomBounds = DeepCopy(EllesmereUIDB.phantomBounds or {}),
}
end
return data
end
--[[ ADDON-SPECIFIC EXPORT DISABLED
--- Snapshot a single addon's profile
function EllesmereUI.SnapshotAddon(folderName)
for _, entry in ipairs(ADDON_DB_MAP) do
if entry.folder == folderName and IsAddonLoaded(folderName) then
local profile = GetAddonProfile(entry)
if profile then return DeepCopy(profile) end
end
end
return nil
end
--- Snapshot multiple addons (for multi-addon export)
function EllesmereUI.SnapshotAddons(folderList)
local data = { addons = {} }
for _, folderName in ipairs(folderList) do
for _, entry in ipairs(ADDON_DB_MAP) do
if entry.folder == folderName and IsAddonLoaded(folderName) then
local profile = GetAddonProfile(entry)
if profile then
data.addons[folderName] = DeepCopy(profile)
end
break
end
end
end
-- Always include fonts and colors
data.fonts = DeepCopy(EllesmereUI.GetFontsDB())
data.customColors = DeepCopy(EllesmereUI.GetCustomColorsDB())
-- Include unlock mode layout data
if EllesmereUIDB then
data.unlockLayout = {
anchors = DeepCopy(EllesmereUIDB.unlockAnchors or {}),
widthMatch = DeepCopy(EllesmereUIDB.unlockWidthMatch or {}),
heightMatch = DeepCopy(EllesmereUIDB.unlockHeightMatch or {}),
phantomBounds = DeepCopy(EllesmereUIDB.phantomBounds or {}),
}
end
return data
end
--]] -- END ADDON-SPECIFIC EXPORT DISABLED
--- Apply imported profile data into the live db.profile tables.
--- Used by import to write external data into the active profile.
--- For normal profile switching, use SwitchProfile (which calls RepointAllDBs).
function EllesmereUI.ApplyProfileData(profileData)
if not profileData or not profileData.addons then return end
-- Build a folder -> db lookup from the Lite registry
local dbByFolder = {}
if EllesmereUI.Lite and EllesmereUI.Lite._dbRegistry then
for _, db in ipairs(EllesmereUI.Lite._dbRegistry) do
if db.folder then dbByFolder[db.folder] = db end
end
end
for _, entry in ipairs(ADDON_DB_MAP) do
local snap = profileData.addons[entry.folder]
if snap and IsAddonLoaded(entry.folder) then
local db = dbByFolder[entry.folder]
if db then
local profile = db.profile
-- TBB and barGlows are spec-specific (in spellAssignments),
-- not in profile. No save/restore needed on profile switch.
for k in pairs(profile) do profile[k] = nil end
for k, v in pairs(snap) do profile[k] = DeepCopy(v) end
if db._profileDefaults then
EllesmereUI.Lite.DeepMergeDefaults(profile, db._profileDefaults)
end
-- Ensure per-unit bg colors are never nil after import
if entry.folder == "EllesmereUIUnitFrames" then
local UF_UNITS = { "player", "target", "focus", "boss", "pet", "totPet" }
local DEF_BG = 17/255
for _, uKey in ipairs(UF_UNITS) do
local s = profile[uKey]
if s and s.customBgColor == nil then
s.customBgColor = { r = DEF_BG, g = DEF_BG, b = DEF_BG }
end
end
end
end
end
end
-- Apply fonts and colors
do
local fontsDB = EllesmereUI.GetFontsDB()
for k in pairs(fontsDB) do fontsDB[k] = nil end
if profileData.fonts then
for k, v in pairs(profileData.fonts) do fontsDB[k] = DeepCopy(v) end
end
if fontsDB.global == nil then fontsDB.global = "Expressway" end
if fontsDB.outlineMode == nil then fontsDB.outlineMode = "shadow" end
end
do
local colorsDB = EllesmereUI.GetCustomColorsDB()
for k in pairs(colorsDB) do colorsDB[k] = nil end
if profileData.customColors then
for k, v in pairs(profileData.customColors) do colorsDB[k] = DeepCopy(v) end
end
end
-- Restore unlock mode layout data
if EllesmereUIDB then
local ul = profileData.unlockLayout
if ul then
EllesmereUIDB.unlockAnchors = DeepCopy(ul.anchors or {})
EllesmereUIDB.unlockWidthMatch = DeepCopy(ul.widthMatch or {})
EllesmereUIDB.unlockHeightMatch = DeepCopy(ul.heightMatch or {})
EllesmereUIDB.phantomBounds = DeepCopy(ul.phantomBounds or {})
end
-- If profile predates unlockLayout, leave live data untouched
end
end
--- Trigger live refresh on all loaded addons after a profile apply.
function EllesmereUI.RefreshAllAddons()
-- ResourceBars (full rebuild)
if _G._ERB_Apply then _G._ERB_Apply() end
-- CDM: skip during spec-profile switch. CDM's own PLAYER_SPECIALIZATION_CHANGED
-- handler will update the active spec key and rebuild with the correct spec
-- spells via OnSpecChanged's deferred FullCDMRebuild. Running it here
-- would use a stale active spec key (not yet updated by CDM) and show the
-- wrong spec's spells until the deferred rebuild overwrites them.
if not EllesmereUI._specProfileSwitching then
if _G._ECME_LoadSpecProfile and _G._ECME_GetCurrentSpecKey then
local curKey = _G._ECME_GetCurrentSpecKey()
if curKey then _G._ECME_LoadSpecProfile(curKey) end
end
if _G._ECME_Apply then _G._ECME_Apply() end
end
-- Cursor (style + position)
if _G._ECL_Apply then _G._ECL_Apply() end
if _G._ECL_ApplyTrail then _G._ECL_ApplyTrail() end
if _G._ECL_ApplyGCDCircle then _G._ECL_ApplyGCDCircle() end
if _G._ECL_ApplyCastCircle then _G._ECL_ApplyCastCircle() end
-- AuraBuffReminders (refresh + position)
if _G._EABR_RequestRefresh then _G._EABR_RequestRefresh() end
if _G._EABR_ApplyUnlockPos then _G._EABR_ApplyUnlockPos() end
-- ActionBars (style + layout + position)
if _G._EAB_Apply then _G._EAB_Apply() end
-- UnitFrames (style + layout + position)
if _G._EUF_ReloadFrames then _G._EUF_ReloadFrames() end
-- Nameplates
if _G._ENP_RefreshAllSettings then _G._ENP_RefreshAllSettings() end
-- Global class/power colors (updates oUF, nameplates, raid frames)
if EllesmereUI.ApplyColorsToOUF then EllesmereUI.ApplyColorsToOUF() end
-- After all addons have rebuilt and positioned their frames from
-- db.profile.positions, re-apply centralized grow-direction positioning
-- (handles lazy migration of imported TOPLEFT positions to CENTER format)
-- and resync anchor offsets so the anchor relationships stay correct for
-- future drags. Triple-deferred so it runs AFTER debounced rebuilds have
-- completed and frames are at final positions.
C_Timer.After(0, function()
C_Timer.After(0, function()
C_Timer.After(0, function()
-- Skip during spec-driven profile switch. _applySavedPositions
-- iterates registered elements and calls each one's
-- applyPosition callback, which for CDM bars is BuildAllCDMBars.
-- That triggers a rebuild + ApplyAllWidthHeightMatches before
-- CDMFinishSetup has had a chance to run, propagating
-- transient mid-rebuild sizes through width-match and
-- corrupting iconSize in saved variables. CDM's OnSpecChanged
-- handles the rebuild at spec_change + 0.5s; other addons'
-- positions don't change on spec swap so skipping is safe.
if EllesmereUI._specProfileSwitching then return end
-- Re-apply centralized positions (migrates legacy formats)
if EllesmereUI._applySavedPositions then
EllesmereUI._applySavedPositions()
end
-- Resync anchor offsets (does NOT move frames)
if EllesmereUI.ResyncAnchorOffsets then
EllesmereUI.ResyncAnchorOffsets()
end
end)
end)
end)
-- Note: _specProfileSwitching is cleared by CDM's OnSpecChanged after
-- its deferred rebuild settles -- not here. CDMFinishSetup runs at
-- spec_change + 0.5s, which is well after this triple-deferred chain
-- (~3 frames = ~50ms), so clearing the flag here would let width-match
-- propagation run against transient mid-rebuild bar sizes once CDM
-- starts rebuilding and corrupt iconSize in saved variables.
end
-------------------------------------------------------------------------------
-- Profile Keybinds
-- Each profile can have a key bound to switch to it instantly.
-- Stored in EllesmereUIDB.profileKeybinds = { ["Name"] = "CTRL-1", ... }
-- Uses hidden buttons + SetOverrideBindingClick, same pattern as Party Mode.
-------------------------------------------------------------------------------
local _profileBindBtns = {} -- [profileName] = hidden Button
local function GetProfileKeybinds()
if not EllesmereUIDB then EllesmereUIDB = {} end
if not EllesmereUIDB.profileKeybinds then EllesmereUIDB.profileKeybinds = {} end
return EllesmereUIDB.profileKeybinds
end
local function EnsureProfileBindBtn(profileName)
if _profileBindBtns[profileName] then return _profileBindBtns[profileName] end
local safeName = profileName:gsub("[^%w]", "")
local btn = CreateFrame("Button", "EllesmereUIProfileBind_" .. safeName, UIParent)
btn:Hide()
btn:SetScript("OnClick", function()
local active = EllesmereUI.GetActiveProfileName()
if active == profileName then return end
local _, profiles = EllesmereUI.GetProfileList()
local fontWillChange = EllesmereUI.ProfileChangesFont(profiles and profiles[profileName])
EllesmereUI.SwitchProfile(profileName)
EllesmereUI.RefreshAllAddons()
if fontWillChange then
EllesmereUI:ShowConfirmPopup({
title = "Reload Required",
message = "Font changed. A UI reload is needed to apply the new font.",
confirmText = "Reload Now",
cancelText = "Later",
onConfirm = function() ReloadUI() end,
})
else
EllesmereUI:RefreshPage()
end
end)
_profileBindBtns[profileName] = btn
return btn
end
function EllesmereUI.SetProfileKeybind(profileName, key)
local kb = GetProfileKeybinds()
-- Clear old binding for this profile
local oldKey = kb[profileName]
local btn = EnsureProfileBindBtn(profileName)
if oldKey then
ClearOverrideBindings(btn)
end
if key then
kb[profileName] = key
SetOverrideBindingClick(btn, true, key, btn:GetName())
else
kb[profileName] = nil
end
end
function EllesmereUI.GetProfileKeybind(profileName)
local kb = GetProfileKeybinds()
return kb[profileName]
end
--- Called on login to restore all saved profile keybinds
function EllesmereUI.RestoreProfileKeybinds()
local kb = GetProfileKeybinds()
for profileName, key in pairs(kb) do
if key then
local btn = EnsureProfileBindBtn(profileName)
SetOverrideBindingClick(btn, true, key, btn:GetName())
end
end
end
--- Update keybind references when a profile is renamed
function EllesmereUI.OnProfileRenamed(oldName, newName)
local kb = GetProfileKeybinds()
local key = kb[oldName]
if key then
local oldBtn = _profileBindBtns[oldName]
if oldBtn then ClearOverrideBindings(oldBtn) end
_profileBindBtns[oldName] = nil
kb[oldName] = nil
kb[newName] = key
local newBtn = EnsureProfileBindBtn(newName)
SetOverrideBindingClick(newBtn, true, key, newBtn:GetName())
end
end
--- Clean up keybind when a profile is deleted
function EllesmereUI.OnProfileDeleted(profileName)
local kb = GetProfileKeybinds()
if kb[profileName] then
local btn = _profileBindBtns[profileName]
if btn then ClearOverrideBindings(btn) end
_profileBindBtns[profileName] = nil
kb[profileName] = nil
end
end
--- Returns true if applying profileData would change the global font or outline mode.
--- Used to decide whether to show a reload popup after a profile switch.
function EllesmereUI.ProfileChangesFont(profileData)
if not profileData or not profileData.fonts then return false end
local cur = EllesmereUI.GetFontsDB()
local curFont = cur.global or "Expressway"
local curOutline = cur.outlineMode or "shadow"
local newFont = profileData.fonts.global or "Expressway"
local newOutline = profileData.fonts.outlineMode or "shadow"
-- "none" and "shadow" are both drop-shadow (no outline) -- treat as identical
if curOutline == "none" then curOutline = "shadow" end
if newOutline == "none" then newOutline = "shadow" end
return curFont ~= newFont or curOutline ~= newOutline
end
--[[ ADDON-SPECIFIC EXPORT DISABLED
--- Apply a partial profile (specific addons only) by merging into active
function EllesmereUI.ApplyPartialProfile(profileData)
if not profileData or not profileData.addons then return end
for folderName, snap in pairs(profileData.addons) do
for _, entry in ipairs(ADDON_DB_MAP) do
if entry.folder == folderName and IsAddonLoaded(folderName) then
local profile = GetAddonProfile(entry)
if profile then
for k, v in pairs(snap) do
profile[k] = DeepCopy(v)
end
end
break
end
end
end
-- Always apply fonts and colors if present
if profileData.fonts then
local fontsDB = EllesmereUI.GetFontsDB()
for k, v in pairs(profileData.fonts) do
fontsDB[k] = DeepCopy(v)
end
end
if profileData.customColors then
local colorsDB = EllesmereUI.GetCustomColorsDB()
for k, v in pairs(profileData.customColors) do
colorsDB[k] = DeepCopy(v)
end
end
end
--]] -- END ADDON-SPECIFIC EXPORT DISABLED
-------------------------------------------------------------------------------
-- Export / Import
-- Format: !EUI_<base64 encoded compressed serialized data>
-- The data table contains:
-- { version = 3, type = "full"|"partial", data = profileData }
-------------------------------------------------------------------------------
local EXPORT_PREFIX = "!EUI_"
function EllesmereUI.ExportProfile(profileName)
local db = GetProfilesDB()
local profileData = db.profiles[profileName]
if not profileData then return nil end
-- If exporting the active profile, ensure fonts/colors/layout are current
if profileName == (db.activeProfile or "Default") then
profileData.fonts = DeepCopy(EllesmereUI.GetFontsDB())
profileData.customColors = DeepCopy(EllesmereUI.GetCustomColorsDB())
profileData.unlockLayout = {
anchors = DeepCopy(EllesmereUIDB.unlockAnchors or {}),
widthMatch = DeepCopy(EllesmereUIDB.unlockWidthMatch or {}),
heightMatch = DeepCopy(EllesmereUIDB.unlockHeightMatch or {}),
phantomBounds = DeepCopy(EllesmereUIDB.phantomBounds or {}),
}
end
local exportData = DeepCopy(profileData)
-- Exclude spec-specific data from export (bar glows, tracking bars)
exportData.trackedBuffBars = nil
exportData.tbbPositions = nil
-- Include spell assignments from the dedicated store on the export copy
-- (barGlows and trackedBuffBars excluded from export -- spec-specific)
local sa = EllesmereUIDB and EllesmereUIDB.spellAssignments
if sa then
local spCopy = DeepCopy(sa.specProfiles or {})
-- Strip spec-specific non-exportable data from each spec profile
for _, prof in pairs(spCopy) do
prof.barGlows = nil
prof.trackedBuffBars = nil
prof.tbbPositions = nil
end
exportData.spellAssignments = {
specProfiles = spCopy,
}
end
local payload = { version = 3, type = "full", data = exportData }
local serialized = Serializer.Serialize(payload)
if not LibDeflate then return nil end
local compressed = LibDeflate:CompressDeflate(serialized)
local encoded = LibDeflate:EncodeForPrint(compressed)
return EXPORT_PREFIX .. encoded
end
--[[ ADDON-SPECIFIC EXPORT DISABLED
function EllesmereUI.ExportAddons(folderList)
local profileData = EllesmereUI.SnapshotAddons(folderList)
local sw, sh = GetPhysicalScreenSize()
local euiScale = EllesmereUIDB and EllesmereUIDB.ppUIScale or (UIParent and UIParent:GetScale()) or 1
local meta = {
euiScale = euiScale,
screenW = sw and math.floor(sw) or 0,
screenH = sh and math.floor(sh) or 0,
}
local payload = { version = 3, type = "partial", data = profileData, meta = meta }
local serialized = Serializer.Serialize(payload)
if not LibDeflate then return nil end
local compressed = LibDeflate:CompressDeflate(serialized)
local encoded = LibDeflate:EncodeForPrint(compressed)
return EXPORT_PREFIX .. encoded
end
--]] -- END ADDON-SPECIFIC EXPORT DISABLED
-------------------------------------------------------------------------------
-- CDM spec profile helpers for export/import spec picker
-------------------------------------------------------------------------------
--- Get info about which specs have data in the CDM specProfiles table.
--- Returns: { { key="250", name="Blood", icon=..., hasData=true }, ... }
--- Includes ALL specs for the player's class, with hasData indicating
--- whether specProfiles contains data for that spec.
function EllesmereUI.GetCDMSpecInfo()
local sa = EllesmereUIDB and EllesmereUIDB.spellAssignments
local specProfiles = sa and sa.specProfiles or {}
local result = {}
local numSpecs = GetNumSpecializations and GetNumSpecializations() or 0
for i = 1, numSpecs do
local specID, sName, _, sIcon = GetSpecializationInfo(i)
if specID then
local key = tostring(specID)
result[#result + 1] = {
key = key,
name = sName or ("Spec " .. key),
icon = sIcon,
hasData = specProfiles[key] ~= nil,
}
end
end
return result
end
--- Filter specProfiles in an export snapshot to only include selected specs.
--- Reads from snapshot.spellAssignments (the dedicated store copy on the payload).
--- Modifies the snapshot in-place. selectedSpecs = { ["250"] = true, ... }
function EllesmereUI.FilterExportSpecProfiles(snapshot, selectedSpecs)
if not snapshot or not snapshot.spellAssignments then return end
local sp = snapshot.spellAssignments.specProfiles
if not sp then return end
for key in pairs(sp) do
if not selectedSpecs[key] then
sp[key] = nil
end
end
end
--- After a profile import, apply only selected specs' specProfiles from the
--- imported data into the dedicated spell assignment store.
--- importedSpellAssignments = the spellAssignments object from the import payload.
--- selectedSpecs = { ["250"] = true, ... }
function EllesmereUI.ApplyImportedSpecProfiles(importedSpellAssignments, selectedSpecs)
if not importedSpellAssignments or not importedSpellAssignments.specProfiles then return end
if not EllesmereUIDB.spellAssignments then
EllesmereUIDB.spellAssignments = { specProfiles = {} }
end
local sa = EllesmereUIDB.spellAssignments
if not sa.specProfiles then sa.specProfiles = {} end
for key, data in pairs(importedSpellAssignments.specProfiles) do
if selectedSpecs[key] then
sa.specProfiles[key] = DeepCopy(data)
end
end
-- If the current spec was imported, reload it live
if _G._ECME_GetCurrentSpecKey and _G._ECME_LoadSpecProfile then
local currentKey = _G._ECME_GetCurrentSpecKey()
if currentKey and selectedSpecs[currentKey] then
_G._ECME_LoadSpecProfile(currentKey)
end
end
end
--- Get the list of spec keys that have data in imported spell assignments.
--- Returns same format as GetCDMSpecInfo but based on imported data.
--- Accepts either the new spellAssignments format or legacy CDM snapshot.
function EllesmereUI.GetImportedCDMSpecInfo(importedSpellAssignments)
if not importedSpellAssignments then return {} end
-- Support both new format (spellAssignments.specProfiles) and legacy (cdmSnap.specProfiles)
local specProfiles = importedSpellAssignments.specProfiles
if not specProfiles then return {} end
local result = {}
for specKey in pairs(specProfiles) do
local specID = tonumber(specKey)
local name, icon
if specID and specID > 0 and GetSpecializationInfoByID then
local _, sName, _, sIcon = GetSpecializationInfoByID(specID)
name = sName
icon = sIcon
end
result[#result + 1] = {
key = specKey,
name = name or ("Spec " .. specKey),
icon = icon,
hasData = true,
}
end
table.sort(result, function(a, b) return a.key < b.key end)
return result
end
-------------------------------------------------------------------------------
-- CDM Spec Picker Popup
-- Thin wrapper around ShowSpecAssignPopup for CDM export/import.
--
-- opts = {
-- title = string,
-- subtitle = string,
-- confirmText = string (button label),
-- specs = { { key, name, icon, hasData, checked }, ... },
-- onConfirm = function(selectedSpecs) -- { ["250"]=true, ... }
-- onCancel = function() (optional)
-- }
-- specs[i].hasData = false grays out the row and shows disabled tooltip.
-- specs[i].checked = initial checked state (only for hasData=true rows).
-------------------------------------------------------------------------------
do
-- Dummy db/dbKey/presetKey for the assignments table
local dummyDB = { _cdmPick = { _cdm = {} } }
function EllesmereUI:ShowCDMSpecPickerPopup(opts)
local specs = opts.specs or {}
-- Reset assignments
dummyDB._cdmPick._cdm = {}
-- Build a set of specIDs that are in the caller's list
local knownSpecs = {}
for _, sp in ipairs(specs) do
local numID = tonumber(sp.key)
if numID then knownSpecs[numID] = sp end
end
-- Build disabledSpecs map (specID -> tooltip string)
-- Any spec NOT in the caller's list gets disabled too
local disabledSpecs = {}
-- Build preCheckedSpecs set
local preCheckedSpecs = {}
for _, sp in ipairs(specs) do
local numID = tonumber(sp.key)
if numID then
if not sp.hasData then
disabledSpecs[numID] = "Create a CDM spell layout for this spec first"
end
if sp.checked then
preCheckedSpecs[numID] = true
end
end
end
-- Disable all specs not in the caller's list (other classes, etc.)
local SPEC_DATA = EllesmereUI._SPEC_DATA
if SPEC_DATA then
for _, cls in ipairs(SPEC_DATA) do
for _, spec in ipairs(cls.specs) do
if not knownSpecs[spec.id] then
disabledSpecs[spec.id] = "Not available for this operation"
end
end
end
end
EllesmereUI:ShowSpecAssignPopup({
db = dummyDB,
dbKey = "_cdmPick",
presetKey = "_cdm",
title = opts.title,
subtitle = opts.subtitle,
buttonText = opts.confirmText or "Confirm",
disabledSpecs = disabledSpecs,