Repository navigation
Expand file tree
/
Copy pathMinimap.lua
More file actions
3322 lines (3042 loc) · 131 KB
/
Copy pathMinimap.lua
File metadata and controls
3322 lines (3042 loc) · 131 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
setfenv(1, _G.SlackHacks)
--[[
Recreates the core feature set of the "Mappy" minimap addon (shape, opacity/fading, coordinates, and
visibility toggles for the zone text/clock/calendar/tracking/extra minimap buttons/border) with simpler
code and no multi-profile system. Minimap's position, size, icon scale, and rotation are left entirely
to Blizzard's own Edit Mode, since Minimap (unlike our custom Buffs container) is already a native Edit
Mode system -- we just attach our own options window to it when it's selected there.
]]--
local module = Self:NewModule("Minimap", "AceEvent-3.0")
Self.Minimap = module
local ROUND_MASK_TEXTURE = "Interface\\CHARACTERFRAME\\TempPortraitAlphaMask"
local SQUARE_MASK_TEXTURE = "Interface\\BUTTONS\\WHITE8X8"
local SQUARE_CLUSTER_WIDTH_EXTRA = 8
local SQUARE_CLUSTER_HEIGHT_EXTRA = 23
local SQUARE_FOREVER_CLUSTER_HEIGHT_EXTRA = 0
-- Edit Mode square selection highlight ("blue box") offsets relative to MinimapCluster.
-- Fine-tunes the Edit Mode selection boundary to align pixel-perfect with our square border.
-- Positive 'top' extends upward; positive 'bottom' insets upward (shortening the box).
-- Positive 'left' insets rightward; positive 'right' extends rightward.
local SQUARE_SELECTION_OFFSETS = {
RETAIL = {
left = 4,
top = 0,
right = 0,
bottom = 1, -- 1px shorter in Retail
},
FOREVER = {
left = 4,
top = 3,
right = 0,
bottom = -1, -- 1px longer in Forever
},
DEFAULT = {
left = 4,
top = 0,
right = 0,
bottom = 0,
},
}
module.selectionOffsets = SQUARE_SELECTION_OFFSETS
-- Falls back to Blizzard's untouched defaults whenever the module is disabled.
local DEFAULT_VISUALS = {
shape = "circle",
showIconsOnHover = false,
showAddonIconsOnHover = false,
hideDiel = false,
showAllMinimapTracking = false,
alpha = 100,
fadeEnabled = false,
combatAlpha = 100,
movingAlpha = 100,
showZoneText = true,
showClock = true,
showTracking = true,
showCalendar = true,
showInstanceDifficulty = true,
showExpansionLandingPage = true,
showAddonCompartment = true,
addonsInCompartment = false,
}
local isSquareMaskApplied = false
--- Returns whether the profile has enabled the module and Ace3 has it active.
---@return boolean - True when minimap mutations are allowed.
local function isModuleEnabled()
return (db and db.profile and db.profile.minimap and db.profile.minimap.enabled) and module:IsEnabled()
end
--- Returns the active profile settings, or native-looking defaults during early startup.
---@return table - Minimap settings safe to read before the database exists.
local function settings()
if db and db.profile and db.profile.minimap then
if db.profile.minimap.showExpansionLandingPage == nil and db.profile.minimap.showGarrison ~= nil then
db.profile.minimap.showExpansionLandingPage = db.profile.minimap.showGarrison
db.profile.minimap.showGarrison = nil
end
return db.profile.minimap
end
return DEFAULT_VISUALS
end
--- Provides the Blizzard-facing shape query used by minimap button addons.
---@return string - `SQUARE` while square mode is active, otherwise `ROUND`.
function _G.GetMinimapShape()
if isModuleEnabled() and settings().shape == "square" then
return "SQUARE"
end
return "ROUND"
end
local isInCombat = false
local isMoving = false
local savedCoordsState
local userWantedRotateMinimap
local optionsDialog
local squareBorderFrame
local titleBarFrame
local titleBarZoneFallbackButton
local titleBarIconRow
local addonIconsContainer
local hoverLeaveTimer
local applyTitleBarLayout
local setFrameStrataSafe
local setFrameLevelRecursive
local updateHoverVisibility
local isButtonShown
local isBlizzardFrame
local disableAllStacking
local registeredButtons = {}
local registeredButtonsByFrame = {}
local addonButtons = {}
local addonButtonsByFrame = {}
local hoverContainerButtons = {}
local origMinimapClusterLayout
local origMinimapClusterWidth, origMinimapClusterHeight
local origMinimapClusterHitInsets
local origMinimapContainerPoints
local squareMinimapWidth, squareMinimapHeight
local origContainerScale
local isSquareClusterApplied = false
local origSetHeaderUnderneath
local origSetRotateMinimap
local origShouldShowSetting
local origSetEditModeScale
local layoutPending = false
--- Forces square minimaps to remain unrotated while remembering the user's preference.
-- Blizzard's square mask cannot represent a rotating world map cleanly, so the CVar is temporarily
-- overridden. The saved preference is restored when returning to round mode or disabling the module.
local function applyMinimapRotation()
if not isModuleEnabled() then return end
local isSquare = (settings().shape == "square")
if isSquare then
local currentCVar = GetCVar("rotateMinimap")
if currentCVar == "1" then
userWantedRotateMinimap = true
if db and db.profile and db.profile.minimap then
db.profile.minimap.savedRotateMinimap = true
end
SetCVar("rotateMinimap", 0)
elseif userWantedRotateMinimap == nil and db and db.profile and db.profile.minimap and db.profile.minimap.savedRotateMinimap then
userWantedRotateMinimap = true
SetCVar("rotateMinimap", 0)
end
else
if MinimapCluster and MinimapCluster.GetSettingValueBool and Enum and Enum.EditModeMinimapSetting and MinimapCluster.HasSetting and MinimapCluster:HasSetting(Enum.EditModeMinimapSetting.RotateMinimap) then
local wantRotate = MinimapCluster:GetSettingValueBool(Enum.EditModeMinimapSetting.RotateMinimap)
SetCVar("rotateMinimap", wantRotate and 1 or 0)
userWantedRotateMinimap = nil
if db and db.profile and db.profile.minimap then
db.profile.minimap.savedRotateMinimap = nil
end
elseif userWantedRotateMinimap or (db and db.profile and db.profile.minimap and db.profile.minimap.savedRotateMinimap) then
SetCVar("rotateMinimap", 1)
userWantedRotateMinimap = nil
if db and db.profile and db.profile.minimap then
db.profile.minimap.savedRotateMinimap = nil
end
end
end
end
--- Restores `rotateMinimap` from Blizzard Edit Mode or the preference saved by square mode.
local function restoreMinimapRotation()
if MinimapCluster and MinimapCluster.GetSettingValueBool and Enum and Enum.EditModeMinimapSetting and MinimapCluster.HasSetting and MinimapCluster:HasSetting(Enum.EditModeMinimapSetting.RotateMinimap) then
local wantRotate = MinimapCluster:GetSettingValueBool(Enum.EditModeMinimapSetting.RotateMinimap)
SetCVar("rotateMinimap", wantRotate and 1 or 0)
userWantedRotateMinimap = nil
if db and db.profile and db.profile.minimap then
db.profile.minimap.savedRotateMinimap = nil
end
elseif userWantedRotateMinimap or (db and db.profile and db.profile.minimap and db.profile.minimap.savedRotateMinimap) then
SetCVar("rotateMinimap", 1)
userWantedRotateMinimap = nil
if db and db.profile and db.profile.minimap then
db.profile.minimap.savedRotateMinimap = nil
end
end
end
--- Resolves the mail indicator across Retail and older client frame layouts.
---@return Frame|nil - Native mail button, if this client exposes one.
local function getMailButton()
if MinimapCluster and MinimapCluster.IndicatorFrame and MinimapCluster.IndicatorFrame.MailFrame then
return MinimapCluster.IndicatorFrame.MailFrame
end
if MiniMapMailFrame then
return MiniMapMailFrame
end
return nil
end
--- Resolves the Retail crafting-order indicator without touching non-Retail clients.
---@return Frame|nil - Native crafting-order button, if available.
local function getCraftingOrderButton()
if not isRetail() then return nil end
if MinimapCluster and MinimapCluster.IndicatorFrame and MinimapCluster.IndicatorFrame.CraftingOrderFrame then
return MinimapCluster.IndicatorFrame.CraftingOrderFrame
end
if MiniMapCraftingOrderFrame then
return MiniMapCraftingOrderFrame
end
return nil
end
--- Resolves the instance-difficulty indicator across current and legacy frame names.
---@return Frame|nil - Native difficulty button, if available.
local function getInstanceDifficultyButton()
if MinimapCluster and MinimapCluster.InstanceDifficulty then
return MinimapCluster.InstanceDifficulty
end
if GuildInstanceDifficulty then
return GuildInstanceDifficulty
end
return nil
end
--- Resolves the current expansion landing-page button.
---@return Frame|nil - Retail or legacy landing page button.
local function getExpansionLandingPageButton()
return ExpansionLandingPageMinimapButton or GarrisonLandingPageMinimapButton
end
--- Checks Blizzard's own landing-page visibility gate before exposing the button.
-- This avoids the legacy API's stale type, which can provide a title even when its backing
-- covenant/expansion data is nil; making that button clickable then crashes Blizzard tooltip/toggle
-- code. `pcall` also makes this probe harmless across API versions.
---@return boolean - True only when the button is valid and should be shown.
local function isExpansionLandingPageAvailable()
if not isRetail() then return false end
local btn = getExpansionLandingPageButton()
if not btn then return false end
-- Newer expansions route through ExpansionOverlay mode (not the legacy type system) --
-- trust Blizzard's own resolved title/description as the availability signal there.
if btn.IsExpansionOverlayMode and btn:IsExpansionOverlayMode() then
return btn.title ~= nil and btn.title ~= ""
end
-- Legacy mode: mirror Blizzard's own RefreshButton() visibility gate
-- (C_Garrison.IsLandingPageMinimapButtonVisible) instead of re-deriving availability ourselves.
-- GetLandingPageGarrisonType() can resolve to a stale/inapplicable type (e.g. Shadowlands Covenant
-- with no active covenant chosen) whose title Blizzard still populates -- forcing the button
-- visible/clickable in that state crashes Blizzard's own SetTooltip/ToggleLandingPage/
-- UpdateButtonTextures (nil title / nil covenantData) since it was never meant to be shown.
if C_Garrison and C_Garrison.GetLandingPageGarrisonType and C_Garrison.IsLandingPageMinimapButtonVisible then
local typeOk, gType = pcall(C_Garrison.GetLandingPageGarrisonType)
if typeOk and gType and gType > 0 then
local visOk, visible = pcall(C_Garrison.IsLandingPageMinimapButtonVisible, gType)
if visOk then return visible and true or false end
end
return false
end
-- Fallback for clients without the modern landing page API (shouldn't normally be reached on retail).
if C_PlayerInfo and C_PlayerInfo.CanPlayerUseExpansionLandingPage then
local ok, canUse = pcall(C_PlayerInfo.CanPlayerUseExpansionLandingPage)
if ok and canUse then return true end
end
if GarrisonLandingPage_HasGarrison then
local ok, hasGarrison = pcall(GarrisonLandingPage_HasGarrison)
if ok and hasGarrison then return true end
end
return false
end
--- Returns standard Blizzard-owned indicators managed by the title bar.
---@return table - Ordered list of currently available native buttons.
local function getStandardMinimapIcons()
local list = {}
if GameTimeFrame then table.insert(list, GameTimeFrame) end
if MinimapCluster and MinimapCluster.Tracking then table.insert(list, MinimapCluster.Tracking) end
if TimeManagerClockButton then table.insert(list, TimeManagerClockButton) end
local mail = getMailButton()
if mail then table.insert(list, mail) end
local crafting = getCraftingOrderButton()
if crafting then table.insert(list, crafting) end
local diff = getInstanceDifficultyButton()
if diff then table.insert(list, diff) end
local landingPage = getExpansionLandingPageButton()
if landingPage then table.insert(list, landingPage) end
if AddonCompartmentFrame then table.insert(list, AddonCompartmentFrame) end
if MinimapCluster and MinimapCluster.DielFrame then table.insert(list, MinimapCluster.DielFrame) end
return list
end
--- Returns extra native indicators that may need hiding in square mode.
---@return table - Buttons including legacy frames that are absent on newer clients.
local function extraButtons()
local mail = getMailButton()
local crafting = getCraftingOrderButton()
local diff = getInstanceDifficultyButton()
local landingPage = getExpansionLandingPageButton()
return {
mail,
crafting,
diff,
landingPage,
AddonCompartmentFrame,
MiniMapBattlefieldFrame,
MiniMapMeetingStoneFrame,
MiniMapVoiceChatFrame,
FeedbackUIButton,
}
end
--- Show/hide is deferred (skipped, not queued) while in combat; PLAYER_REGEN_ENABLED re-applies everything.
---@param frame Frame|nil - Frame whose visibility should change.
---@param shown boolean - Desired visibility.
local function setShownSafely(frame, shown)
if not frame or InCombatLockdown() then return end
frame:SetShown(shown)
end
--- Applies the selected minimap mask, reverting only a mask installed by this module.
local function applyShape()
if not isModuleEnabled() then return end
if not _G.Minimap.SetMaskTexture then return end -- not available on this client build; shape stays default
if settings().shape == "square" then
_G.Minimap:SetMaskTexture(SQUARE_MASK_TEXTURE)
isSquareMaskApplied = true
elseif isSquareMaskApplied then
-- Only revert a mask we ourselves swapped in; never touch Blizzard's own default round mask otherwise.
_G.Minimap:SetMaskTexture(ROUND_MASK_TEXTURE)
isSquareMaskApplied = false
end
end
--- Applies configured alpha, including combat/movement fades and the indoor full-alpha safeguard.
local function applyAlpha()
if not isModuleEnabled() then return end
local mm = settings()
local alphaPercent = mm.alpha or 100
if mm.fadeEnabled then
if isInCombat then
alphaPercent = mm.combatAlpha or alphaPercent
elseif isMoving then
alphaPercent = mm.movingAlpha or alphaPercent
end
end
if alphaPercent > 0 and IsIndoors() then
alphaPercent = 100 -- avoid a solid-black minimap indoors at less than full alpha
end
local clampedPercent = math.max(0, math.min(100, alphaPercent)) -- avoid relying on the global `Clamp`
local alpha = clampedPercent / 100
if MinimapCluster then
MinimapCluster:SetAlpha(alpha)
end
-- If Minimap is not a child of MinimapCluster, set its alpha directly;
-- otherwise, it inherits MinimapCluster's alpha (keep its own local alpha at 1 to prevent double-fading).
if not MinimapCluster or (_G.Minimap:GetParent() ~= MinimapCluster and _G.Minimap:GetParent() ~= (MinimapCluster and MinimapCluster.MinimapContainer)) then
_G.Minimap:SetAlpha(alpha)
else
_G.Minimap:SetAlpha(1)
end
if squareBorderFrame and squareBorderFrame:GetParent() ~= MinimapCluster then
squareBorderFrame:SetAlpha(alpha)
end
if titleBarFrame and titleBarFrame:GetParent() ~= MinimapCluster then
titleBarFrame:SetAlpha(alpha)
end
if addonIconsContainer and addonIconsContainer:GetParent() ~= MinimapCluster and addonIconsContainer:GetParent() ~= titleBarFrame then
addonIconsContainer:SetAlpha(alpha)
end
end
--- Resolves Blizzard's player-coordinate widget across client layouts.
---@return Frame|nil - Coordinate overlay frame, if present.
local function getBlizzardPlayerCoords()
if MinimapCluster and MinimapCluster.MinimapContainer and MinimapCluster.MinimapContainer.PlayerCoords then
return MinimapCluster.MinimapContainer.PlayerCoords
end
if MinimapCluster and MinimapCluster.PlayerCoords then
return MinimapCluster.PlayerCoords
end
if _G.Minimap and _G.Minimap.PlayerCoords then
return _G.Minimap.PlayerCoords
end
if _G.PlayerCoords then
return _G.PlayerCoords
end
return nil
end
--- Restores the coordinate widget's parent, anchors, level, strata, and overridden methods.
local function restoreCoordinates()
local coords = getBlizzardPlayerCoords()
if not coords then return end
if coords.slackHacksRealSetPoint then
coords.SetPoint = coords.slackHacksRealSetPoint
coords.ClearAllPoints = coords.slackHacksRealClearAllPoints
coords.slackHacksRealSetPoint = nil
coords.slackHacksRealClearAllPoints = nil
end
if savedCoordsState then
if savedCoordsState.parent then
coords:SetParent(savedCoordsState.parent)
end
coords:SetFrameStrata(savedCoordsState.strata)
coords:SetFrameLevel(savedCoordsState.level)
coords:ClearAllPoints()
for _, a in ipairs(savedCoordsState.anchors) do
coords:SetPoint(a.point, a.relativeTo, a.relativePoint, a.x, a.y)
end
savedCoordsState = nil
end
end
--- Pins Blizzard's coordinate widget inside the square minimap without letting later layout calls move it.
-- Its methods are temporarily no-ops because Blizzard can re-anchor this protected overlay during its
-- own refresh. The original methods and every anchor are restored when leaving square mode.
local function applyCoordinates()
if not isModuleEnabled() then return end
local coords = getBlizzardPlayerCoords()
if not coords then return end
if settings().shape == "square" then
if not savedCoordsState then
local ok, numPoints = pcall(coords.GetNumPoints, coords)
local anchors = {}
if ok and numPoints then
for i = 1, numPoints do
local pOk, point, relativeTo, relativePoint, x, y = pcall(coords.GetPoint, coords, i)
if pOk and point then
table.insert(anchors, { point = point, relativeTo = relativeTo, relativePoint = relativePoint, x = x, y = y })
end
end
end
savedCoordsState = {
parent = coords:GetParent(),
level = coords:GetFrameLevel(),
strata = coords:GetFrameStrata(),
anchors = anchors,
}
end
if not coords.slackHacksRealSetPoint then
coords.slackHacksRealSetPoint = coords.SetPoint
coords.slackHacksRealClearAllPoints = coords.ClearAllPoints
coords.SetPoint = function() end
coords.ClearAllPoints = function() end
end
coords:SetParent(_G.Minimap)
coords:SetFrameStrata(_G.Minimap:GetFrameStrata())
coords:SetFrameLevel(_G.Minimap:GetFrameLevel() + 5)
coords.slackHacksRealClearAllPoints(coords)
coords.slackHacksRealSetPoint(coords, "BOTTOM", _G.Minimap, "BOTTOM", 0, 4)
else
restoreCoordinates()
end
end
--- Resolves the native zone-text button, including the legacy global fallback.
---@return Button|nil - Blizzard zone-text button.
local function getZoneTextButton()
return (MinimapCluster and MinimapCluster.ZoneTextButton) or _G.MinimapZoneTextButton
end
--- Applies the zone-text visibility setting without mutating the native button's behavior.
local function applyZoneText()
if not isModuleEnabled() then return end
local mm = settings()
local zoneButton = getZoneTextButton()
setShownSafely(zoneButton, mm.showZoneText)
end
--- Returns all native icon frames whose borders may need to be hidden or restored.
---@return table - Native indicator frames and tracking sub-button.
local function getAllBlizzardIcons()
local list = getStandardMinimapIcons()
if AddonCompartmentFrame then table.insert(list, AddonCompartmentFrame) end
if MinimapCluster and MinimapCluster.Tracking and MinimapCluster.Tracking.Button then
table.insert(list, MinimapCluster.Tracking.Button)
end
return list
end
--- Hides or restores Blizzard border textures without replacing their click behavior.
---@param removeBorders boolean - True to hide border art while retaining the frames.
local function applyBlizzardIconBorders(removeBorders)
for _, btn in ipairs(getAllBlizzardIcons()) do
if btn.slackHacksCreatedBorder then
btn.slackHacksCreatedBorder:Hide()
btn.slackHacksCreatedBorder:SetTexture(nil)
btn.slackHacksCreatedBorder = nil
end
if btn == TimeManagerClockButton and TimeManagerClockBackground then
TimeManagerClockBackground:SetAlpha(removeBorders and 0 or 1)
end
local ok, regions = pcall(function() return { btn:GetRegions() } end)
if ok and regions then
for _, r in ipairs(regions) do
if r:IsObjectType("Texture") then
local tex = r:GetTexture()
local atlas = r.GetAtlas and r:GetAtlas()
local isBorder = (tex == 136430)
or (type(tex) == "string" and (tex:find("MiniMap%-TrackingBorder") or tex:find("UI%-Minimap%-Background")))
or (tex == 136467)
or (atlas and (atlas:lower():find("frame%-cycle") or atlas:lower():find("tracking%-border") or atlas:lower():find("button%-border")))
if btn == TimeManagerClockButton then
isBorder = true
end
if isBorder then
r:SetAlpha(removeBorders and 0 or 1)
end
end
end
end
end
end
--- Applies the clock visibility setting.
local function applyClock()
if not isModuleEnabled() then return end
local mm = settings()
setShownSafely(TimeManagerClockButton, mm.showClock)
end
--- Applies the calendar visibility setting.
local function applyCalendar()
if not isModuleEnabled() then return end
setShownSafely(GameTimeFrame, settings().showCalendar)
end
--- MinimapCluster.DielFrame is the oversized day/night ("diel" = 24-hour cycle) sun/moon icon shown on
--- Classic/Forever's minimap; it has no counterpart shown on Retail's minimap.
local function applyDielFrame()
if not isModuleEnabled() then return end
local mm = settings()
local shown = true
if mm.shape == "square" then
shown = false -- doesn't fit the title bar; always hidden there
elseif not isRetail() and mm.hideDiel then
shown = false
end
setShownSafely(MinimapCluster and MinimapCluster.DielFrame, shown)
end
--- Applies the tracking-button visibility setting.
local function applyTracking()
if not isModuleEnabled() then return end
setShownSafely(MinimapCluster and MinimapCluster.Tracking, settings().showTracking)
end
--- Applies the optional `minimapTrackingShowAll` CVar setting.
local function applyTrackingCVar()
if not isModuleEnabled() then return end
if settings().showAllMinimapTracking then
ensureCVar("minimapTrackingShowAll", 1) -- Show all minimap tracking options (including turning off target tracking!)
else
ensureCVar("minimapTrackingShowAll", GetCVarDefault("minimapTrackingShowAll"))
end
end
--- Places or hides secondary minimap buttons in square mode.
-- The actual show/hide calls are combat-gated because several Blizzard minimap frames are protected.
local function applyExtraButtons()
if not isModuleEnabled() then return end
local isSquare = (settings().shape == "square")
if not isSquare then return end
local legacyExtras = {
MiniMapBattlefieldFrame,
MiniMapMeetingStoneFrame,
MiniMapVoiceChatFrame,
FeedbackUIButton,
}
for _, btn in ipairs(legacyExtras) do
setShownSafely(btn, false)
end
end
--- Same nine-slice border art used by many Blizzard windows, but the plain rectangular layout (no
--- portrait icon/title notch cut into the top-left corner like the Spellbook's own frame has).
--- Drawn above _G.Minimap so the NineSlice metal corners and beveled edges neatly enclose the map
--- texture without clipping or inner seams, matching how Blizzard's SpellBook and DefaultPanelTemplate work.
--- Also hosts a 2D backing frame behind _G.Minimap using Blizzard's FlatPanelBackgroundTemplate so
--- curved bottom corners (uiframebackground-nineslice-cornerbottom...) fill the perimeter inset without bleeding.
---@return Frame - The cached square border frame.
local function createSquareBorder()
if squareBorderFrame then return squareBorderFrame end
local frame = CreateFrame("Frame", "SlackHacksMinimapSquareBorder", MinimapCluster or _G.Minimap)
frame.layoutType = "ButtonFrameTemplateNoPortrait"
frame:SetFrameStrata(_G.Minimap:GetFrameStrata())
frame:SetFrameLevel(math.max(500, _G.Minimap:GetFrameLevel() + 5))
frame:EnableMouse(false)
frame:SetPoint("TOPLEFT", MinimapCluster or _G.Minimap, "TOPLEFT", 0, 0)
frame:SetPoint("BOTTOMRIGHT", MinimapCluster or _G.Minimap, "BOTTOMRIGHT", 0, 0)
-- 2D backing frame sitting behind _G.Minimap to provide a clean dark inset behind the map viewport.
-- Uses Blizzard's FlatPanelBackgroundTemplate which has curved corner textures matching the nine-slice art.
local backing = CreateFrame("Frame", "SlackHacksMinimapSquareBacking", frame, "FlatPanelBackgroundTemplate")
backing:SetFrameStrata(_G.Minimap:GetFrameStrata())
backing:SetFrameLevel(math.max(1, _G.Minimap:GetFrameLevel() - 1))
backing:EnableMouse(false)
backing:ClearAllPoints()
backing:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -21)
backing:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
local bgColor = PANEL_BACKGROUND_COLOR or (CreateColor and CreateColor(0.08, 0.08, 0.08, 1))
if bgColor then
if backing.BottomLeft then backing.BottomLeft:SetVertexColor(bgColor:GetRGBA()) end
if backing.BottomRight then backing.BottomRight:SetVertexColor(bgColor:GetRGBA()) end
if backing.BottomEdge then backing.BottomEdge:SetVertexColor(bgColor:GetRGBA()) end
if backing.TopSection then backing.TopSection:SetVertexColor(bgColor:GetRGBA()) end
end
local nineSlice = CreateFrame("Frame", nil, frame, "NineSlicePanelTemplate")
nineSlice:EnableMouse(false)
-- Re-assert AFTER creating the nine-slice (its OnLoad can flip mouse back on for a draggable-dialog
-- style frame). Also HookScript OnShow, since Show()/re-layout can retrigger the same behavior later
-- (confirmed via /framestack: this border kept winning mouse focus over the map/POI pins underneath it
-- even after the one-time post-creation EnableMouse(false) call).
frame:EnableMouse(false)
frame:HookScript("OnShow", function(self) self:EnableMouse(false) end)
squareBorderFrame = frame
return frame
end
--- Snapshots native cluster geometry before square mode takes ownership of it.
-- The snapshot is refreshed whenever we genuinely re-enter from round mode so Edit Mode resizes made
-- while square mode is active are reflected when the native round layout is restored.
local function cacheMinimapClusterDefaults()
if not MinimapCluster then return end
-- Re-snapshot every time we're coming from genuine round state (not just once ever) --
-- otherwise a mid-session Edit Mode resize of the minimap while square mode is active
-- goes unnoticed, and restoring round mode later reverts to the stale, now-mismatched
-- cluster/border size while the actual Minimap texture keeps its new (larger) size.
if isSquareClusterApplied then return end
if MinimapCluster.GetWidth and MinimapCluster:GetWidth() > 0 then
origMinimapClusterWidth = MinimapCluster:GetWidth()
origMinimapClusterHeight = MinimapCluster:GetHeight()
end
if MinimapCluster.GetHitRectInsets then
origMinimapClusterHitInsets = { MinimapCluster:GetHitRectInsets() }
end
if _G.Minimap and _G.Minimap.GetWidth and _G.Minimap:GetWidth() > 0 then
squareMinimapWidth = _G.Minimap:GetWidth()
squareMinimapHeight = _G.Minimap:GetHeight()
end
local container = MinimapCluster.MinimapContainer
if container and container.GetScale then
origContainerScale = container:GetScale()
end
if container and container.GetNumPoints and container:GetNumPoints() > 0 then
origMinimapContainerPoints = {}
for i = 1, container:GetNumPoints() do
local point, relativeTo, relativePoint, offsetX, offsetY = container:GetPoint(i)
origMinimapContainerPoints[i] = {
point = point,
relativeTo = relativeTo,
relativePoint = relativePoint,
offsetX = offsetX,
offsetY = offsetY,
}
end
end
end
--- Converts the native minimap size and container scale into visible square dimensions.
---@return number, number, number - Visual width, visual height, and container scale.
local function getSquareMinimapDimensions()
local container = MinimapCluster and MinimapCluster.MinimapContainer
local scale = (container and container:GetScale()) or 1
if not scale or scale <= 0 then scale = 1 end
local mmW = squareMinimapWidth or 198
local mmH = squareMinimapHeight or 198
local visualW = math.floor((mmW * scale) + 0.5)
local visualH = math.floor((mmH * scale) + 0.5)
return visualW, visualH, scale
end
--- Returns the active square Edit Mode selection offsets (left, top, right, bottom) for the current (or specified) game client.
---@param clientKey string|nil - "RETAIL", "FOREVER", "CLASSIC", or current client if nil.
---@return number, number, number, number - left, top, right, bottom offsets
local function getSquareSelectionOffsets(clientKey)
local offsets = module.selectionOffsets or SQUARE_SELECTION_OFFSETS
local key = clientKey
or (isRetail and isRetail() and "RETAIL")
or (isForever and isForever() and "FOREVER")
or (isClassic and isClassic() and "CLASSIC")
or "DEFAULT"
local cfg = offsets[key] or offsets.DEFAULT or SQUARE_SELECTION_OFFSETS.DEFAULT
return cfg.left or 0, cfg.top or 0, cfg.right or 0, cfg.bottom or 0
end
--- Snaps Edit Mode Selection directly to MinimapCluster so its blue drag bounds and snapping match.
local function applySquareSelectionBounds()
if not (MinimapCluster and MinimapCluster.Selection) then return end
local selLeft, selTop, selRight, selBottom = getSquareSelectionOffsets()
MinimapCluster.Selection:ClearAllPoints()
MinimapCluster.Selection:SetPoint("TOPLEFT", MinimapCluster, "TOPLEFT", selLeft, selTop)
MinimapCluster.Selection:SetPoint("BOTTOMRIGHT", MinimapCluster, "BOTTOMRIGHT", selRight, selBottom)
if MinimapCluster.Selection.SetClipsChildren then
MinimapCluster.Selection:SetClipsChildren(true)
end
end
--- Replaces Blizzard's round cluster layout with the square border/title-bar geometry.
-- This is skipped in combat because cluster sizing and anchoring are protected; the regen event calls
-- `ApplyAll` to catch up. Blizzard's layout method is temporarily neutralized so it cannot immediately
-- undo the custom points.
local function applySquareMinimapCluster()
if not MinimapCluster then return end
if InCombatLockdown() then return end -- MinimapCluster:SetSize() is protected in combat; PLAYER_REGEN_ENABLED re-applies
cacheMinimapClusterDefaults()
-- Disable Blizzard's MinimapCluster layout so it doesn't fight our sizing or points
if not origMinimapClusterLayout and MinimapCluster.Layout then
origMinimapClusterLayout = MinimapCluster.Layout
end
MinimapCluster.Layout = function() end
-- Remove/hide the native header (BorderTop) and related elements in square mode
if MinimapCluster.BorderTop then
MinimapCluster.BorderTop:SetAlpha(0)
MinimapCluster.BorderTop:Hide()
MinimapCluster.BorderTop.ignoreInLayout = true
end
if MinimapCluster.IndicatorFrame then
MinimapCluster.IndicatorFrame:Hide()
end
if MinimapCluster.GamepadButtons then
MinimapCluster.GamepadButtons:Hide()
end
if MinimapCluster.SetClipsChildren then
MinimapCluster:SetClipsChildren(false)
end
-- Position MinimapContainer so the map sits cleanly below the title bar and within the borders
local visualW, visualH, scale = getSquareMinimapDimensions()
local container = MinimapCluster.MinimapContainer
if container then
container:ClearAllPoints()
container:SetPoint("CENTER", MinimapCluster, "CENTER", 2 / scale, -9 / scale)
end
-- Size MinimapCluster to match the visible square minimap + border
MinimapCluster:SetSize(visualW + SQUARE_CLUSTER_WIDTH_EXTRA, visualH + SQUARE_CLUSTER_HEIGHT_EXTRA + (isForever() and SQUARE_FOREVER_CLUSTER_HEIGHT_EXTRA or 0))
if MinimapCluster.SetHitRectInsets then
MinimapCluster:SetHitRectInsets(0, 0, 0, 0)
end
applySquareSelectionBounds()
-- Keep the square border anchored to MinimapCluster; Edit Mode selection offsets match its bounds.
local border = createSquareBorder()
if border then
border:ClearAllPoints()
border:SetPoint("TOPLEFT", MinimapCluster, "TOPLEFT", 0, 0)
border:SetPoint("BOTTOMRIGHT", MinimapCluster, "BOTTOMRIGHT", 0, 0)
border:Show()
border:EnableMouse(false) -- OnShow only refires the hook on hidden->shown transitions, not repeat Show() calls while already shown
end
if MinimapCluster.UpdateClampOffsets then
MinimapCluster:UpdateClampOffsets()
end
isSquareClusterApplied = true
end
--- Restores native cluster geometry, anchors, Edit Mode settings, and layout behavior.
-- Native indicators are detached before the indicator frame is shown; otherwise Blizzard can evaluate
-- a stale custom anchor and report a circular-anchor error during its own layout cascade.
local function restoreRoundMinimapCluster()
if not MinimapCluster or not isSquareClusterApplied then return end
if InCombatLockdown() then return end -- MinimapCluster:SetSize() is protected in combat; PLAYER_REGEN_ENABLED re-applies
-- Unhijack/restore Mail, Crafting Orders, GameTimeFrame, Tracking, etc. to their native anchors
-- BEFORE showing IndicatorFrame -- otherwise Blizzard's own layout cascade (triggered by Show())
-- can hit a stale real anchor still pointing into our title bar hierarchy and throw a circular
-- anchor error ("Cannot anchor to a region dependent on it").
disableAllStacking()
-- Restore MinimapContainer points
local container = MinimapCluster.MinimapContainer
if container then
container:ClearAllPoints()
if origMinimapContainerPoints and #origMinimapContainerPoints > 0 then
for _, pt in ipairs(origMinimapContainerPoints) do
container:SetPoint(pt.point, pt.relativeTo, pt.relativePoint, pt.offsetX, pt.offsetY)
end
elseif container.defaultFramePoints then
local scale = container:GetScale() or 1
for _, value in ipairs(container.defaultFramePoints) do
container:SetPoint(value.point, value.relativeTo, value.relativePoint, value.offsetX / scale, value.offsetY / scale)
end
else
container:SetPoint("TOP", MinimapCluster, "TOP", 10, -30)
end
end
-- Restore MinimapCluster size and insets, scaled up/down to match any Edit Mode size change
-- made while square mode was active (our cached round dimensions are otherwise stale, leaving
-- the round border sized for the old scale while the actual Minimap texture uses the new one).
local currentScale = container and container.GetScale and container:GetScale()
local scaleRatio = 1
if currentScale and currentScale > 0 and origContainerScale and origContainerScale > 0 then
scaleRatio = currentScale / origContainerScale
end
MinimapCluster:SetSize((origMinimapClusterWidth or 256) * scaleRatio, (origMinimapClusterHeight or 256) * scaleRatio)
if MinimapCluster.SetHitRectInsets then
if origMinimapClusterHitInsets then
MinimapCluster:SetHitRectInsets(unpack(origMinimapClusterHitInsets))
else
MinimapCluster:SetHitRectInsets(30, 10, 0, 30)
end
end
-- Restore native header and indicator frame
if MinimapCluster.BorderTop then
MinimapCluster.BorderTop:SetAlpha(1)
MinimapCluster.BorderTop:Show()
MinimapCluster.BorderTop.ignoreInLayout = nil
end
if MinimapCluster.IndicatorFrame then
MinimapCluster.IndicatorFrame:Show()
end
if MinimapCluster.GamepadButtons then
MinimapCluster.GamepadButtons:Show()
end
-- Restore Edit Mode selection
if MinimapCluster.Selection then
MinimapCluster.Selection:ClearAllPoints()
MinimapCluster.Selection:SetAllPoints(MinimapCluster)
if MinimapCluster.Selection.SetClipsChildren then
MinimapCluster.Selection:SetClipsChildren(false)
end
end
if MinimapCluster.UpdateClampOffsets then
MinimapCluster:UpdateClampOffsets()
end
-- Restore Layout
if origMinimapClusterLayout then
MinimapCluster.Layout = origMinimapClusterLayout
end
-- Re-apply Blizzard's saved HeaderUnderneath and RotateMinimap settings
local isHeaderUnderneath = false
if MinimapCluster.GetSettingValueBool and Enum and Enum.EditModeMinimapSetting and MinimapCluster.HasSetting and MinimapCluster:HasSetting(Enum.EditModeMinimapSetting.HeaderUnderneath) then
isHeaderUnderneath = MinimapCluster:GetSettingValueBool(Enum.EditModeMinimapSetting.HeaderUnderneath)
end
if origSetHeaderUnderneath then
pcall(origSetHeaderUnderneath, MinimapCluster, isHeaderUnderneath)
elseif MinimapCluster.SetHeaderUnderneath then
pcall(MinimapCluster.SetHeaderUnderneath, MinimapCluster, isHeaderUnderneath)
end
local isRotateMinimap = false
if MinimapCluster.GetSettingValueBool and Enum and Enum.EditModeMinimapSetting and MinimapCluster.HasSetting and MinimapCluster:HasSetting(Enum.EditModeMinimapSetting.RotateMinimap) then
isRotateMinimap = MinimapCluster:GetSettingValueBool(Enum.EditModeMinimapSetting.RotateMinimap)
end
if origSetRotateMinimap then
pcall(origSetRotateMinimap, MinimapCluster, isRotateMinimap)
elseif MinimapCluster.SetRotateMinimap then
pcall(MinimapCluster.SetRotateMinimap, MinimapCluster, isRotateMinimap)
end
if MinimapCluster.Layout then
pcall(function() MinimapCluster:Layout() end)
end
isSquareClusterApplied = false
end
--- Chooses square or native Edit Mode selection bounds based on current settings.
local function updateEditModeSelectionBounds()
if not (MinimapCluster and MinimapCluster.Selection) then return end
if not isModuleEnabled() then
restoreRoundMinimapCluster()
return
end
local mm = settings()
if mm.shape == "square" then
applySquareMinimapCluster()
applySquareSelectionBounds()
else
restoreRoundMinimapCluster()
end
end
--- Applies the border presentation and keeps Edit Mode's selection rectangle aligned with it.
local function applyBorder()
if not isModuleEnabled() then return end
local mm = settings()
local isSquare = (mm.shape == "square")
if MinimapBackdrop then
MinimapBackdrop:SetAlpha(isSquare and 0 or 1)
end
if isSquare then
applySquareMinimapCluster()
applyBlizzardIconBorders(true)
else
if squareBorderFrame then
squareBorderFrame:Hide()
end
applyBlizzardIconBorders(false)
restoreRoundMinimapCluster()
end
updateEditModeSelectionBounds()
end
local TITLE_BAR_HEIGHT = 18
local TITLE_BAR_ICON_SCALE = 0.7
local TITLE_BAR_CLOCK_SCALE = 1.1 -- the clock's text/frame proportions read as too small at the icon scale
local TITLE_BAR_EXPANSION_LANDING_PAGE_SCALE = 0.7
local TITLE_BAR_CALENDAR_TRACKING_SCALE = 1.05
local TITLE_BAR_ADDON_COMPARTMENT_SIZE_DELTA = 1
local TITLE_BAR_CALENDAR_Y_OFFSET = -1 -- calendar icon/date art sits slightly higher within its frame than other icons
local TITLE_BAR_TRACKING_Y_OFFSET = 2 -- lifts tracking to vertically align with title bar
local TITLE_BAR_ADDON_COMPARTMENT_Y_OFFSET = isRetail() and 1 or 0 -- Retail: raises addon compartment one pixel
local TITLE_BAR_ICON_Y_OFFSET = isRetail() and -1 or 0 -- shifts non-clock title-bar icons down on Retail
local TITLE_BAR_CLOCK_Y_OFFSET = isRetail() and -2 or -1 -- Retail: shifts clock down one additional pixel
local TITLE_BAR_CLOCK_X_OFFSET = isRetail() and 6 or 3 -- Retail: shifts clock 3 pixels farther right; preserve Forever
local TITLE_BAR_ZONE_X_OFFSET = isRetail() and -2 or 0 -- Retail: shifts zone text three pixels left from its previous position
local TITLE_BAR_ZONE_Y_OFFSET = isRetail() and -1 or 0 -- Retail: shifts zone text down one pixel
local TITLE_BAR_CLOCK_WIDTH_DELTA = -10 -- shorten clock width by 10 pixels
local TITLE_BAR_RETAIL_MAIL_Y_OFFSET = isRetail() and 2 or 0 -- Retail: raises mail two pixels
local TITLE_BAR_RETAIL_CALENDAR_Y_OFFSET = isRetail() and -0.5 or 0 -- Retail: lowers calendar half a pixel
--- Sits in the visual banner area of the square border, hosting the zone text (left) and shrunk
--- minimap icons (right) in square mode. Parented to the minimap itself (not the border) so it isn't
--- hidden when "Show Border" is off. Anchored just inside the border's own box near its top edge --
--- matching Blizzard's own TitleContainer convention (PortraitFrameBaseTemplate/DefaultPanelBaseTemplate
--- anchor their title text at y=-1 relative to the frame box; the banner artwork itself bleeds upward
--- past that box via the nine-slice corner atlas's own y offset, so the box's top edge is already where
--- the readable part of the banner sits, not further above it).
--- Frame level 510 matches Blizzard's own PortraitFrameTemplate TitleContainer convention: the
--- NineSlicePanelTemplate border art is hardcoded to level 500, so 510 is what actually draws above it.
---@return Frame - Cached title-bar frame.
local function createTitleBar()
if titleBarFrame then return titleBarFrame end
local border = createSquareBorder()
local frame = CreateFrame("Frame", "SlackHacksMinimapTitleBar", MinimapCluster or _G.Minimap)
frame:SetHeight(TITLE_BAR_HEIGHT)
frame:SetPoint("TOPLEFT", border, "TOPLEFT", 12, -2)
frame:SetPoint("TOPRIGHT", border, "TOPRIGHT", -12, -2)
frame:SetFrameStrata(_G.Minimap:GetFrameStrata())
frame:SetFrameLevel(510)
-- A shared wrapper so every repositioned icon anchors to the same reference frame (same strata/level
-- as the bar itself) instead of each one separately guessing at the bar's own edge.
local iconRow = CreateFrame("Frame", "SlackHacksMinimapTitleBarIcons", frame)
iconRow:SetPoint("RIGHT", frame, "RIGHT", 0, 0)
iconRow:SetSize(1, TITLE_BAR_HEIGHT)
iconRow:SetFrameStrata(frame:GetFrameStrata())
iconRow:SetFrameLevel(510)
titleBarIconRow = iconRow
titleBarFrame = frame
return frame
end
--- Purely a positioning/hit-test reference frame -- never a real parent for Blizzard's own buttons.
--- (Blizzard's OnClick handlers for things like AddonCompartmentFrame/Tracking/Calendar rely on their
--- native ancestry (MinimapCluster) and get silently tainted/broken if reparented to a plain addon-created
--- frame, even though tooltips still work fine since those are insecure.) Always shown so `isMinimapHovered()`
--- can treat its rect as "still hovering the icon group", independent of any individual button's visibility.
---@return Frame - Transparent hit-test container; it is not a button parent.
local function createAddonIconsContainer()
if addonIconsContainer then return addonIconsContainer end
local container = CreateFrame("Frame", "SlackHacksMinimapAddonIconsContainer", titleBarFrame or MinimapCluster or UIParent)
container:SetHeight(TITLE_BAR_HEIGHT)
container:SetFrameStrata("DIALOG")
container:SetFrameLevel(525)
addonIconsContainer = container
return container
end
--- Safely tests pointer state across frame API generations.
---@param region Frame|nil - Candidate region.
---@return boolean - True when the pointer is currently over the shown region.
local function isRegionMouseOver(region)
if not region or not region.IsShown or not region:IsShown() then return false end
local ok, result = pcall(function()
if region.IsMouseOver then
return region:IsMouseOver()
elseif MouseIsOver then
return MouseIsOver(region)