-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMovableWindows.lua
More file actions
1825 lines (1624 loc) · 92.5 KB
/
Copy pathMovableWindows.lua
File metadata and controls
1825 lines (1624 loc) · 92.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
setfenv(1, _G.SlackHacks)
--=====================================================================
-- Movable Windows
--=====================================================================
-- Lets you drag most Blizzard windows around by their title bar, and resize them with the mouse wheel.
-- Reviewed directly against Blizzard's own FrameXML, specifically `PanelDragBarMixin`
-- (Blizzard_SharedXML/SharedUIPanelTemplates.lua) -- the exact native drag-bar mixin Blizzard's own
-- movable panels use. Every registered window gets the same unprotected child overlay, sized to just its
-- title-bar strip (not the whole window), inheriting the real "PanelDragBarTemplate" (so dragging goes
-- through Blizzard's own frame:StartMoving()/StopMovingOrSizing() path, not a custom position hack, and
-- still works on protected frames outside combat lockdown).
--
-- This only ships a curated subset of commonly-used frames (not an exhaustive, version-gated database of
-- every frame across every WoW expansion back to Vanilla) since we only target current retail and WoW
-- Forever (Classic Era). Use module:RegisterFrame(frameName, frameData) to add more.
--
-- Two windows get special handling instead of the generic drag handle: Blizzard's toast popups (moved via
-- the real Edit Mode) and the Zone Map (moved by its own tab, with a "Change Scale" tab-menu slider). See
-- their own sections below for why.
local module = Self:NewModule("MovableWindows", "AceEvent-3.0", "AceHook-3.0")
Self.MovableWindows = module
--=====================================================================
-- Constants & state
--=====================================================================
local MIN_SCALE = 0.3 -- steps are 0.1, kept above 0.25 so nothing can shrink to invisible
local MAX_SCALE = 2.5
local DEFAULT_TITLE_BAR_HEIGHT = 20 -- matches most Blizzard windows' native title/border strip height
-- Blizzard's own portrait/title-bar decorations (NineSlice, TitleContainer, CloseButton, etc.) are commonly
-- placed at up to frame:GetFrameLevel()+510 (see PortraitFrameMixin:SetFrameLevelsFromBaseLevel); the drag
-- handle needs to sit above all of that or those elements silently eat the mousedown before it ever arrives.
local TITLE_BAR_HANDLE_LEVEL_OFFSET = 1000
local FAKE_UI_PARENT_NAME = "SlackHacksMovableWindowsFakeUIParent"
-- Blizzard's alert/toast popups -- achievements, notable items (mounts/toys/recipes/BoE epics/etc.), honor,
-- garrison, etc. -- all get anchored under this one global container frame.
local TOAST_FRAME_NAME = "AlertFrame"
-- A stand-in for UIParent: dragged frames get anchored relative to this instead of the real UIParent,
-- since UIParent itself can be protected/tainted in ways that make re-anchoring to it directly unreliable.
local fakeUIParent = CreateFrame("Frame", FAKE_UI_PARENT_NAME, nil, "SecureFrameTemplate")
fakeUIParent:SetAllPoints(UIParent)
local frameRegistry = {} -- [frame] = frameData
local registeredFrames = {} -- top-level [frameName] = frameData (what we try to (re)process)
local moveHandles = {} -- [handleFrame] = true
local mouseoverFrames = {} -- [frame] = true
local sessionScales = {} -- [frameName] = scale, cleared on reload
local combatLockdownQueue = {}
local setFramePointsQueue = {}
local ignoreSetPointHook = false
local mouseWheelCaptureFrame
local queueProcessorFrame
local awaitingGlobalMouseUp
local toastMover
local toastSelection
local toastHooked
-- Forward declarations for functions referenced before their definition further down the file.
local onMouseDown, onMouseUp, onMouseWheel, onShow, onSetPoint, onSizeUpdate, checkMouseWheelCapture
local registerToastEditMode
local setupZoneMap, setZoneMapScale
--=====================================================================
-- Settings helpers
--=====================================================================
--- Shorthand for this module's slice of the saved-variables DB. Centralized in one place (rather than
--- inlining `db.profile.movableWindows` everywhere) so the DB layout only has to be known here.
---@return table settings - The module's profile settings table.
local function settings()
return db.profile.movableWindows
end
--- Whether the module's own on/off switch is enabled. Checked before doing any work so a disabled module
--- costs nothing at runtime (no hooks fire, no frames get processed).
---@return boolean enabled
local function isModuleEnabled()
return settings() and settings().enabled or false
end
--- Resolves a user-configured modifier-key setting (one of "SHIFT"/"CTRL"/"ALT"/"NONE") to whether that
--- key is currently held. Shared by both the move-modifier and scale-modifier checks below so the two
--- independently configurable modifiers (e.g. move with no modifier, scale with Shift) use identical logic.
---@param key string - "SHIFT", "CTRL", "ALT", or "NONE".
---@return boolean isDown
local function isModifierKeyDown(key)
if key == "SHIFT" then return IsShiftKeyDown() end
if key == "CTRL" then return IsControlKeyDown() end
if key == "ALT" then return IsAltKeyDown() end
return true -- "NONE": no modifier required
end
--- Whether the user's configured "move" modifier key is currently held.
---@return boolean isDown
local function isMoveModifierDown()
return isModifierKeyDown(settings().modifierKey)
end
--- Whether the user's configured "scale" (mouse-wheel-resize) modifier key is currently held.
---@return boolean isDown
local function isScaleModifierDown()
return isModifierKeyDown(settings().scaleModifierKey)
end
--=====================================================================
-- Frame name / registry helpers
--=====================================================================
--- Looks up the global name a registered frame was registered under. Used when saving a position/anchor
--- so the anchor can be serialized as a stable string (survives reload) instead of a live frame reference.
---@param frame Frame? - A frame previously registered via module:RegisterFrame() (directly or as a SubFrame).
---@return string? frameName
local function getFrameName(frame)
local frameData = frame and frameRegistry[frame]
return frameData and frameData.storage and frameData.storage.frameName
end
--- Resolves a global frame name back to the live frame object, following dotted paths (e.g.
--- "Parent.ChildFrame") one segment at a time through the `_G` table. Used to re-resolve a saved anchor's
--- `relativeFrame` name back into a real frame reference when reapplying a saved position.
---@param frameName string - Global name, optionally dotted (e.g. "CharacterFrame" or "Parent.ChildFrame").
---@return Frame? frame - nil if any segment of the path doesn't currently exist.
local function getFrameFromName(frameName)
local object = _G
for key in frameName:gmatch("([^.]+)") do
if not object[key] then return nil end
object = object[key]
end
return object
end
--=====================================================================
-- Combat lockdown queue
--=====================================================================
--- Runs `func(...)` immediately if not in combat, or defers it until combat ends. Protected frames can't
--- be reparented/repositioned/hooked while in combat lockdown, so anything that needs to touch one has to
--- funnel through here instead of just failing/erroring mid-fight.
---@param func function - Function to call (immediately or once combat ends).
---@param ... any - Arguments to pass to func.
local function addToCombatLockdownQueue(func, ...)
if not InCombatLockdown() then
func(...)
return
end
if #combatLockdownQueue == 0 then
module:RegisterEvent("PLAYER_REGEN_ENABLED")
end
tinsert(combatLockdownQueue, { func = func, args = { ... } })
end
--- AceEvent handler for PLAYER_REGEN_ENABLED (combat ends): flushes and runs every queued deferred call
--- from addToCombatLockdownQueue(), in the order they were queued, then unregisters itself again since
--- there's nothing to listen for until the queue has something in it again.
function module:PLAYER_REGEN_ENABLED()
self:UnregisterEvent("PLAYER_REGEN_ENABLED")
if #combatLockdownQueue == 0 then return end
local queued = combatLockdownQueue
combatLockdownQueue = {}
for _, item in ipairs(queued) do
item.func(unpack(item.args))
end
end
--=====================================================================
-- Frame position helpers
--=====================================================================
--- Captures a frame's CURRENT anchor point(s) exactly as SetPoint currently has them, resolving any
--- registered relative frame to its stable name (so it survives a reload) rather than a live reference.
--- Used only to remember a detachable subframe's original (parent-relative) anchor before we detach it,
--- so RightButton+Alt can put it back exactly where Blizzard originally anchored it.
---@param frame Frame - The frame whose current anchor points should be captured.
---@return table? points - Array of {anchorPoint, relativeFrame, relativePoint, offX, offY}, or nil if the
--- frame currently has no points at all.
local function capturePoints(frame)
local numPoints = frame:GetNumPoints()
if not numPoints or numPoints == 0 then return nil end
local points = {}
for i = 1, numPoints do
local anchorPoint, relativeFrame, relativePoint, offX, offY = frame:GetPoint(i)
local relativeFrameName
if relativeFrame then
relativeFrameName = getFrameName(relativeFrame) or (relativeFrame.GetName and relativeFrame:GetName())
end
points[i] = {
anchorPoint = anchorPoint,
relativeFrame = relativeFrameName or relativeFrame,
relativePoint = relativePoint,
offX = offX,
offY = offY,
}
end
return points
end
--- Converts a frame's current on-screen position into a single anchor point relative to whichever screen
--- edge (or center) it's actually closest to -- e.g. a frame near the bottom-left becomes
--- `{anchorPoint = "BOTTOMLEFT", relativeFrame = FAKE_UI_PARENT_NAME, ...}` with small offsets, instead of
--- an arbitrary absolute pixel position. This is what makes a saved drag position still look right after
--- a UI reload or resolution/aspect-ratio change: an edge-relative anchor scales naturally with the
--- screen, while a raw absolute coordinate would not. Always anchors relative to our own `fakeUIParent`
--- stand-in (see its declaration above) rather than the real UIParent, for the same taint-safety reason
--- setFramePoint() below routes through it.
---@param frame Frame - The frame whose current screen position should be captured.
---@return table? points - A single-entry array (same shape as capturePoints()) usable with setFramePoints().
local function getAbsoluteFramePosition(frame)
local scale = frame:GetScale()
if not scale or not frame:GetLeft() then return nil end
local left, top = frame:GetLeft() * scale, frame:GetTop() * scale
local right, bottom = frame:GetRight() * scale, frame:GetBottom() * scale
local screenWidth, screenHeight = GetScreenWidth(), GetScreenHeight()
local horizontalOffset = (left + right) / 2 - screenWidth / 2
local verticalOffset = (top + bottom) / 2 - screenHeight / 2
local x, y, point = 0, 0, ""
if left < (screenWidth - right) and left < abs(horizontalOffset) then
x, point = left, "LEFT"
elseif (screenWidth - right) < abs(horizontalOffset) then
x, point = right - screenWidth, "RIGHT"
else
x = horizontalOffset
end
if bottom < (screenHeight - top) and bottom < abs(verticalOffset) then
y, point = bottom, "BOTTOM" .. point
elseif (screenHeight - top) < abs(verticalOffset) then
y, point = top - screenHeight, "TOP" .. point
else
y = verticalOffset
end
if point == "" then point = "CENTER" end
return {
{
anchorPoint = point,
relativeFrame = FAKE_UI_PARENT_NAME,
relativePoint = point,
offX = x,
offY = y,
},
}
end
local secureAnchorFrame = CreateFrame("Frame", nil, nil, "SecureHandlerBaseTemplate")
--- Calls the frame's real, un-hooked SetPoint (bypassing our own onSetPoint watchdog hook further below),
--- so we can reposition a frame ourselves without that same call re-triggering our own "something moved
--- this frame, reassert the dragged position" anti-rubberband logic.
---@param frame Frame
---@param anchorPoint string
---@param relativeFrame Frame?
---@param relativePoint string
---@param offX number
---@param offY number
local function realSetPoint(frame, anchorPoint, relativeFrame, relativePoint, offX, offY)
local setPoint = frame.SetPointBase or frame.SetPoint
setPoint(frame, anchorPoint, relativeFrame, relativePoint, offX, offY)
end
--- Applies one saved anchor point to a frame. Resolves a string relativeFrame name back to a live frame
--- (via _G), and redirects any saved reference to the real UIParent onto our own `fakeUIParent` stand-in
--- (see its declaration above) since re-anchoring directly to UIParent can be unreliable/tainted for some
--- protected frames. Setting a point directly is fine in the overwhelming majority of cases, but if the
--- anchor target itself turns out to be protected/forbidden outside of combat, that's routed through a
--- `SecureHandlerBaseTemplate` snippet instead (Execute() runs with Blizzard's own execution context) so
--- our own insecure code can never be blamed for tainting anything downstream of that protected frame.
---@param frame Frame - The frame to reposition.
---@param point table - One entry from capturePoints()/getAbsoluteFramePosition(): {anchorPoint,
--- relativeFrame, relativePoint, offX, offY}.
---@param scale number - Divides offX/offY by this (the frame's own GetScale()) so saved pixel offsets,
--- which were captured in that same scale, still land in the same visual spot regardless of scale changes.
local function setFramePoint(frame, point, scale)
ignoreSetPointHook = true
local relativeFrame = point.relativeFrame
if type(relativeFrame) == "string" then
relativeFrame = _G[relativeFrame]
end
if relativeFrame == UIParent then
relativeFrame = fakeUIParent
end
if not InCombatLockdown() and (not relativeFrame or select(2, relativeFrame:IsProtected())) then
secureAnchorFrame:SetFrameRef("frame", frame)
if relativeFrame then
secureAnchorFrame:SetFrameRef("relativeFrame", relativeFrame)
end
secureAnchorFrame:SetAttribute("hasRelativeFrame", relativeFrame and true or false)
secureAnchorFrame:SetAttribute("anchorPoint", point.anchorPoint)
secureAnchorFrame:SetAttribute("relativePoint", point.relativePoint)
secureAnchorFrame:SetAttribute("offX", point.offX / scale)
secureAnchorFrame:SetAttribute("offY", point.offY / scale)
secureAnchorFrame:Execute([[
local frame = self:GetFrameRef("frame")
local relativeFrame
if self:GetAttribute("hasRelativeFrame") then
relativeFrame = self:GetFrameRef("relativeFrame")
end
frame:SetPoint(self:GetAttribute("anchorPoint"), relativeFrame, self:GetAttribute("relativePoint"), self:GetAttribute("offX"), self:GetAttribute("offY"))
]])
else
realSetPoint(frame, point.anchorPoint, relativeFrame, point.relativePoint, point.offX / scale, point.offY / scale)
end
ignoreSetPointHook = false
end
--- Applies a full saved point list (as produced by capturePoints()/getAbsoluteFramePosition()) to a
--- frame, clearing any existing points first. This is the one function actually responsible for moving a
--- frame to a saved/dragged position -- everything else (drag handlers, the onSetPoint watchdog, Edit Mode
--- toast dragging) eventually funnels through this.
---@param frame Frame - The frame to reposition. No-op (returns false) if it's protected and we're in combat.
---@param points table? - Point list to apply; no-op (returns false) if nil/empty.
---@param raw boolean? - If true, skip dividing offsets by the frame's scale (points are already in that
--- frame's own local units, e.g. for the toast mover which isn't scale-adjusted).
---@return boolean applied
local function setFramePoints(frame, points, raw)
if InCombatLockdown() and frame:IsProtected() then return false end
if not points or not points[1] then return false end
frame:ClearAllPoints()
local scale = raw and 1 or frame:GetScale()
for _, point in ipairs(points) do
setFramePoint(frame, point, scale)
end
return true
end
--- Queues a frame to have setFramePoints() re-applied on the very next frame update, instead of calling
--- it immediately. Calling SetPoint again from directly inside our own SetPoint hook (onSetPoint below)
--- can be unreliable/re-entrant, so the "permanent" strategy's rubberband watchdog defers to here instead
--- of reapplying synchronously. Multiple queue requests for the same frame within one frame just overwrite
--- each other (last write wins) since only the final target position before the next update matters.
---@param frame Frame
---@param points table - Point list to apply on the next frame update.
local function addToSetFramePointsQueue(frame, points)
if setFramePointsQueue[frame] then return end
setFramePointsQueue[frame] = points
if not queueProcessorFrame then
queueProcessorFrame = CreateFrame("Frame")
end
queueProcessorFrame:SetScript("OnUpdate", function(self)
self:SetScript("OnUpdate", nil)
for queuedFrame, queuedPoints in pairs(setFramePointsQueue) do
setFramePoints(queuedFrame, queuedPoints)
end
wipe(setFramePointsQueue)
end)
end
--- Wires up frameData.storage.points, the in-memory table that tracks a frame's drag/detach state. Under
--- the "session" save strategy this is just a plain scratch table (cleared on reload). Under "permanent"
--- it's instead aliased *by reference* to settings().points[frameName], so any later mutation (a drag, a
--- detach) is automatically persisted with no separate save step, and is naturally restored next login
--- since the same (already-populated) saved-variable table gets re-aliased here again. Also validates any
--- previously-saved detachPoints on load: if the frame it was detached-anchored to no longer exists (e.g.
--- from a different addon/version), the detached state is discarded rather than silently re-attaching to
--- a Frame that doesn't exist.
---@param frame Frame
---@param frameData table - The registered frameData for this frame; must already have `.storage.frameName` set.
---@return boolean ok - false only if frameData.storage.frameName is unset (shouldn't normally happen).
local function setupPointStorage(frame, frameData)
local frameName = frameData.storage.frameName
if not frameName then return false end
if settings().savePositionStrategy ~= "permanent" then
frameData.storage.points = frameData.storage.points or {}
return true
end
if frameData.storage.points and frameData.storage.points == settings().points[frameName] then return true end
settings().points[frameName] = settings().points[frameName] or {}
frameData.storage.points = settings().points[frameName]
if frameData.storage.points.detachPoints then
local firstPoint = frameData.storage.points.detachPoints[1]
local relativeFrameName = firstPoint and firstPoint.relativeFrame
if type(relativeFrameName) == "string" and getFrameFromName(relativeFrameName) then
frameData.storage.detached = true
else
wipe(frameData.storage.points)
end
end
return true
end
--=====================================================================
-- Frame scale helpers
--=====================================================================
--- The frame's EFFECTIVE scale relative to the whole registry tree, i.e. its own GetScale() compounded
--- with its registered parent's effective scale (recursively) -- unless ManuallyScaleWithParent is set,
--- in which case the parent relationship is skipped since that subframe already scales itself in lockstep
--- with the parent via other means (see setFrameScaleSubs below) and shouldn't be double-counted.
---@param frame Frame - Must already be registered (a key in frameRegistry).
---@return number effectiveScale
local function getFrameScale(frame)
local frameData = frameRegistry[frame]
local parentScale = (frameData.storage.frameParent and not frameData.ManuallyScaleWithParent and getFrameScale(frameData.storage.frameParent)) or 1
return frame:GetScale() * parentScale
end
--- Propagates a parent frame's scale change down to its registered SubFrames, keeping each subframe's
--- EFFECTIVE (on-screen) size constant across the parent's rescale, with one exception per subframe:
--- - ManuallyScaleWithParent subframes that are still attached: their own GetScale() is nudged so their
--- effective size tracks the parent's new scale (rather than staying visually the same size the parent
--- just changed).
--- - Detached subframes that do NOT want to scale with the parent: since a detached subframe is no longer
--- visually parented under it, its own GetScale() is compensated in the opposite direction so its actual
--- effective size doesn't silently change just because some *other*, no-longer-related frame rescaled.
--- - Everything else (still-attached, non-ManuallyScaleWithParent subframes): scale simply follows the
--- parent naturally through Blizzard's own frame hierarchy, so just recurse to handle any of ITS own
--- subframes the same way.
---@param frame Frame - The parent frame whose scale just changed.
---@param oldScale number - Its effective scale before the change.
---@param newScale number - Its effective scale after the change.
local function setFrameScaleSubs(frame, oldScale, newScale)
local frameData = frameRegistry[frame]
if not frameData.SubFrames then return end
for _, subFrameData in pairs(frameData.SubFrames) do
local subFrame = subFrameData.storage and subFrameData.storage.frame
if subFrame then
if subFrameData.ManuallyScaleWithParent and not subFrameData.storage.detached then
subFrame:SetScale((subFrame:GetScale() / oldScale) * newScale)
elseif not subFrameData.ManuallyScaleWithParent and subFrameData.storage.detached then
subFrame:SetScale((oldScale * subFrame:GetScale()) / newScale)
else
setFrameScaleSubs(subFrame, oldScale, newScale)
end
end
end
end
--- Sets a registered frame's scale to (as close as possible to) the requested EFFECTIVE scale, persisting
--- it (both to the saved-variable table and the session-only cache) and cascading the change to any
--- registered SubFrames via setFrameScaleSubs() so they don't visually shrink/grow just because their
--- parent did. No-ops (but returns true, i.e. "handled") if the frame is currently protected mid-combat,
--- since scale changes on protected frames are combat-restricted the same way position changes are.
---@param frame Frame - Must already be registered.
---@param requestedScale number - Desired effective (compounded) scale.
---@return boolean handled - false only if the frame isn't registered at all.
local function setFrameScale(frame, requestedScale)
local frameData = frameRegistry[frame]
if not frameData then return false end
if InCombatLockdown() and frame:IsProtected() then return true end
local oldScale = getFrameScale(frame)
local newScale = requestedScale
if frameData.storage.detached then
local parentScale = getFrameScale(frameData.storage.frameParent)
newScale = frameData.ManuallyScaleWithParent and requestedScale or (requestedScale / parentScale)
elseif frameData.ManuallyScaleWithParent then
newScale = getFrameScale(frameData.storage.frameParent)
end
local frameName = frameData.storage.frameName
settings().scales[frameName] = newScale
sessionScales[frameName] = newScale
frame:SetScale(newScale)
setFrameScaleSubs(frame, oldScale, newScale)
return true
end
--=====================================================================
-- Movement start/stop (shared between direct EnableMouse frames and secure move handles)
--=====================================================================
--- Begins dragging `frame`. For a plain (non-protected) frame this is just its own real StartMoving(),
--- called from our own insecure OnMouseDown handler. For a PanelDragBarTemplate move handle wrapping a
--- protected frame, we can't call StartMoving() ourselves from an insecure OnMouseDown/OnDragStart --
--- Blizzard's PanelDragBarMixin already has its own native OnDragStart wired up via
--- `RegisterForDrag("LeftButton")`, which is what's actually allowed to call the protected frame's
--- StartMoving(). `onDragStartCallback` returning falsy (its default, set on creation) is what makes that
--- native handler bail out immediately -- so "arming" the drag here means just clearing that callback so
--- the *next* native OnDragStart (from the real drag gesture already in progress) is allowed to proceed.
---@param frame Frame - Either the frame itself, or (for protected frames) its move-handle overlay.
local function startMoving(frame)
if moveHandles[frame] then
-- Arm the handle's own native OnDragStart (already listening via PanelDragBarTemplate's RegisterForDrag)
-- so Blizzard's own StartMoving() call fires from a real drag event, not from our insecure hook.
frame.onDragStartCallback = nil
return
end
frame:StartMoving()
end
--- Ends dragging `frame`, mirroring startMoving()'s move-handle special case: rearms the handle's
--- `onDragStartCallback` back to its default "block" state so the NEXT drag gesture also has to originate
--- from a real native OnDragStart before StartMoving() can fire again.
---@param frame Frame - Either the frame itself, or (for protected frames) its move-handle overlay.
local function stopMoving(frame)
if moveHandles[frame] then
frame.onDragStartCallback = function() return false end
return
end
frame:StopMovingOrSizing()
end
--=====================================================================
-- Mouse handlers
--=====================================================================
--- Core mousedown handler shared by every registered frame's move handle. Recurses up through
--- frameData.storage.frameParent first (so, e.g., mousedown on a still-attached subframe's handle is
--- treated as if it happened on the root window, unless that subframe has since been Detached), then
--- handles three distinct gestures at whichever level in the chain actually owns the interaction:
--- - Left-click + Alt (only if Detachable and not already detached): detaches the subframe from its
--- parent, remembering its original anchor (via capturePoints()) so it can be reattached later.
--- - Left-click (any other case, or if already detached): begins a native Blizzard drag via startMoving().
--- - Right-click + Alt (only if currently detached): reattaches the subframe to its original saved anchor.
--- - Right-click + the scale modifier: resets scale to 1.
--- - Right-click + Shift: clears any dragged position, snapping back to whatever anchor Blizzard's own
--- code (or our own detach-restore) currently has it pointed at.
---@param frame Frame - The (possibly non-root) registered frame the mousedown conceptually applies to.
---@param button string - "LeftButton" or "RightButton".
---@param moveHandle Frame? - The PanelDragBarTemplate overlay actually receiving input, if any (nil for
--- frames that are directly EnableMouse()'d instead of using a handle).
---@return boolean handled - Whether this call (or a parent in the chain) did something with the click.
local function doOnMouseDown(frame, button, moveHandle)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return false end
setupPointStorage(frame, frameData)
local returnValue, parentReturnValue = false, false
if button == "LeftButton" then
if not moveHandle and IsAltKeyDown() and frameData.Detachable and not frameData.storage.detached then
frameData.storage.points.detachPoints = capturePoints(frame)
frameData.storage.detached = true
returnValue = true
PlaySound((SOUNDKIT and SOUNDKIT.IG_CHARACTER_INFO_OPEN) or 839)
end
if not frameData.storage.detached then
parentReturnValue = (frameData.storage.frameParent and doOnMouseDown(frameData.storage.frameParent, button, moveHandle)) or false
end
if (frameData.storage.detached or not parentReturnValue) and isMoveModifierDown() then
local userPlaced = frame:IsUserPlaced()
frame:SetMovable(true)
startMoving(moveHandle or frame)
frame:SetUserPlaced(userPlaced)
frameData.storage.points.startPoints = frameData.storage.points.startPoints or getAbsoluteFramePosition(frame)
frameData.storage.isMoving = true
returnValue = true
end
end
return returnValue or parentReturnValue
end
--- Public entry point wired to every move handle's (and any directly-EnableMouse'd frame's) OnMouseDown.
--- Resolves a move-handle overlay back to the real frame it belongs to (handles are children reparented
--- under the frame they control -- see makeMoveHandle) before delegating to doOnMouseDown().
---@param frame Frame - The frame that actually received the mousedown (may be a move handle).
---@param button string
function onMouseDown(frame, button)
local moveHandle = moveHandles[frame] and frame or nil
if moveHandle then frame = moveHandle:GetParent() end
return doOnMouseDown(frame, button, moveHandle)
end
--- Mouse-up counterpart to doOnMouseDown(): ends any in-progress drag, snapshots and persists the frame's
--- new position via getAbsoluteFramePosition(), and marks it "dragged" so the onSetPoint watchdog knows
--- to defend this position against being overwritten later. Also handles finishing the RightButton+Alt
--- reattach / scale-reset / Shift-reset gestures doOnMouseDown() started evaluating. Recurses up the
--- parent chain the same way doOnMouseDown() does, for the same reason (attached subframes defer to root).
---@param frame Frame
---@param button string
---@param moveHandle Frame?
---@return boolean handled
local function doOnMouseUp(frame, button, moveHandle)
if moveHandle then stopMoving(moveHandle) end
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return false end
local returnValue, parentReturnValue = false, false
if not frameData.storage.detached then
parentReturnValue = (frameData.storage.frameParent and doOnMouseUp(frameData.storage.frameParent, button, moveHandle)) or false
end
if frameData.storage.detached or not parentReturnValue then
if button == "LeftButton" and frameData.storage.isMoving then
stopMoving(moveHandle or frame)
frameData.storage.points.dragPoints = getAbsoluteFramePosition(frame)
frameData.storage.points.dragged = true
frameData.storage.isMoving = nil
returnValue = true
-- When strategy == "permanent", storage.points IS (by reference) settings().points[frameName], so
-- this mutation is already persisted -- no separate save step needed (see setupPointStorage above).
setFramePoints(frame, frameData.storage.points.dragPoints)
elseif button == "RightButton" then
local fullReset = false
if IsAltKeyDown() and frameData.storage.detached then
if setFramePoints(frame, frameData.storage.points.detachPoints, true) then
frameData.storage.points.detachPoints = nil
frameData.storage.detached = nil
returnValue = true
fullReset = true
PlaySound((SOUNDKIT and SOUNDKIT.IG_CHARACTER_INFO_CLOSE) or 840)
end
end
if isScaleModifierDown() or fullReset then
returnValue = setFrameScale(frame, 1) or returnValue
end
if IsShiftKeyDown() or fullReset then
if frameData.storage.points then
if not fullReset and frameData.storage.points.startPoints then
setFramePoints(frame, frameData.storage.points.startPoints)
frameData.storage.points.startPoints = nil
end
frameData.storage.points.dragPoints = nil
frameData.storage.points.dragged = nil
end
returnValue = true
end
end
end
return returnValue or parentReturnValue
end
--- Public entry point wired to every move handle's OnMouseUp/OnDragStop (and the global-mouse-up safety
--- net below). Resolves handle -> real frame the same way onMouseDown() does, then delegates to
--- doOnMouseUp().
---@param frame Frame
---@param button string
function onMouseUp(frame, button)
local moveHandle = moveHandles[frame] and frame or nil
if moveHandle then frame = moveHandle:GetParent() end
return doOnMouseUp(frame, button, moveHandle)
end
--- Core mouse-wheel-to-scale handler shared by every registered frame, recursing up the parent chain the
--- same way doOnMouseDown()/doOnMouseUp() do (an attached subframe's wheel input scales the root window
--- unless it's Detached). Scale changes in increments of 0.1 per wheel notch, clamped to
--- [MIN_SCALE, MAX_SCALE].
---@param frame Frame
---@param delta number - Wheel delta from OnMouseWheel (positive = scroll up/scale in, negative = down/out).
---@return boolean handled
local function doOnMouseWheel(frame, delta)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return false end
local returnValue, parentReturnValue = false, false
if not frameData.storage.detached then
parentReturnValue = (frameData.storage.frameParent and doOnMouseWheel(frameData.storage.frameParent, delta)) or false
end
if frameData.storage.detached or not parentReturnValue then
local oldScale = getFrameScale(frame) or 1
local newScale = max(MIN_SCALE, min(MAX_SCALE, oldScale + 0.1 * delta))
returnValue = setFrameScale(frame, newScale) or returnValue
end
return returnValue or parentReturnValue
end
--- Public entry point for mouse-wheel scaling. Gates on both the feature's own on/off setting and the
--- scale modifier currently being held (this module's whole point is that mouse wheel keeps its normal
--- behavior -- e.g. scrolling a list -- unless the modifier is held), then delegates to doOnMouseWheel().
---@param frame Frame
---@param delta number
function onMouseWheel(frame, delta)
if not settings().enableScaling or not isScaleModifierDown() then return false end
return doOnMouseWheel(frame, delta)
end
--- Marks a registered frame as moused-over for the mouse-wheel-capture arbitration frame (see
--- checkMouseWheelCapture() below), and re-evaluates capture immediately so wheel scaling can engage the
--- instant the modifier is already held when the mouse enters.
---@param frame Frame
local function onEnter(frame)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return end
mouseoverFrames[frame] = true
checkMouseWheelCapture()
end
--- Un-marks a frame as moused-over and re-evaluates wheel capture (see onEnter() above).
---@param frame Frame
local function onLeave(frame)
if not mouseoverFrames[frame] then return end
mouseoverFrames[frame] = nil
checkMouseWheelCapture()
end
--- Hooked to every registered frame's OnShow. Doesn't restore POSITION here (that's entirely the
--- onSetPoint watchdog's job, see below, since Blizzard's own code re-anchoring the frame on Show is
--- exactly the case that watchdog exists to catch) -- this only reapplies a previously-saved/session SCALE,
--- since SetScale (unlike SetPoint) isn't something we hook/watchdog the same way. Reruns itself once via
--- RunNextFrame() (skipRerun guards against infinite recursion) because some frames' own OnShow logic
--- resets scale-affecting state a frame later than their own Show fires.
---@param frame Frame
---@param skipRerun boolean? - Internal guard; omit when calling externally.
function onShow(frame, skipRerun)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return end
if InCombatLockdown() and frame:IsProtected() then
addToCombatLockdownQueue(onShow, frame)
return
end
local frameName = frameData.storage.frameName
-- Position isn't restored here -- it's handled entirely by the onSetPoint watchdog below, which
-- reasserts frameData.storage.points.dragPoints (in-memory for "session", saved-variable-backed via
-- table aliasing for "permanent") whenever Blizzard's own code re-anchors the frame, e.g. on every Show.
local scaleStrategy = settings().saveScaleStrategy
if scaleStrategy == "permanent" and settings().scales[frameName] then
setFrameScale(frame, settings().scales[frameName])
elseif sessionScales[frameName] then
setFrameScale(frame, sessionScales[frameName])
end
if not skipRerun then
RunNextFrame(function() onShow(frame, true) end)
end
end
--- Starts listening for the next GLOBAL_MOUSE_UP event so a drag that's still "in progress" from the
--- registry's point of view (frameData.storage.isMoving) can be properly finalized even if the actual
--- mouse-up happened somewhere our own OnMouseUp handler never received it (see onSubFrameHide() below
--- for why that can happen).
---@param frame Frame - The (root) frame whose drag should be finalized on the next mouse-up anywhere.
local function waitForGlobalMouseUp(frame)
awaitingGlobalMouseUp = frame
module:RegisterEvent("GLOBAL_MOUSE_UP")
end
--- Hooked to a registered SUBFRAME's OnHide (never the root window's). If a subframe is hidden mid-drag --
--- e.g. Blizzard swaps tabs/pages and hides the very panel you're dragging -- its own OnMouseUp will never
--- fire (a hidden frame stops receiving mouse events), which would otherwise leave frameData.storage
--- permanently stuck thinking a drag is still in progress. Recurses to the ROOT frame's storage (drags are
--- always tracked/finalized at the root, see doOnMouseDown/doOnMouseUp) and, if that root thinks it's still
--- mid-drag, falls back to waitForGlobalMouseUp() to catch the mouse-up wherever it actually lands.
---@param frame Frame - The subframe that was just hidden.
local function onSubFrameHide(frame)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return end
local parent = frameData.storage.frameParent
if parent then return onSubFrameHide(parent) end
if frameData.storage.isMoving then
waitForGlobalMouseUp(frame)
end
end
--- AceEvent handler for the one-shot GLOBAL_MOUSE_UP registration from waitForGlobalMouseUp(): finalizes
--- the stranded drag by calling the normal onMouseUp() path, then unregisters itself again (this event
--- fires on every mouse-up game-wide, so we only ever want to listen for exactly one before going quiet).
---@param event string - Always "GLOBAL_MOUSE_UP".
---@param button string
function module:GLOBAL_MOUSE_UP(event, button)
self:UnregisterEvent(event)
if not awaitingGlobalMouseUp then return end
onMouseUp(awaitingGlobalMouseUp, button)
awaitingGlobalMouseUp = nil
end
--- Anti-rubberband watchdog, secure-hooked to every registered frame's real SetPoint. If Blizzard's own
--- code (or another addon) re-anchors a frame we've previously dragged -- e.g. simply reopening the
--- window, or some other frame's layout logic repositioning it -- this immediately reasserts the dragged
--- position instead of silently losing it to whatever SetPoint call just happened. Only applies to a
--- frame that's either the root of its chain or has been Detached (an attached, non-root subframe's
--- position is governed entirely by its parent, so it has nothing of its own to defend). Skipped entirely
--- while `ignoreSetPointHook` is true, which setFramePoint() sets around its OWN SetPoint calls so this
--- watchdog doesn't treat US moving the frame as something to fight back against. Under the "permanent"
--- strategy, defers via addToSetFramePointsQueue() instead of reapplying synchronously (safer from
--- directly inside a SetPoint hook -- see that function's own docs).
---@param frame Frame - The frame whose SetPoint was just (really) called.
function onSetPoint(frame)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return end
if settings().savePositionStrategy == "off" then return end
if ignoreSetPointHook then return end
frameData.storage.points = frameData.storage.points or {}
if
frameData.storage.points.dragged
and (not frameData.storage.frameParent or frameData.storage.detached)
then
if settings().savePositionStrategy ~= "permanent" then
setFramePoints(frame, frameData.storage.points.dragPoints)
else
addToSetFramePointsQueue(frame, frameData.storage.points.dragPoints)
end
end
end
--- Secure-hooked to every registered frame's SetWidth/SetHeight. Recomputes SetClampRectInsets so a frame
--- can never be dragged/scaled so far off-screen that none of it remains visible/grabbable -- specifically,
--- at least `clampDistance` pixels of the frame must always stay on-screen on every edge, regardless of
--- how big the frame currently is (hence recomputing on every size change, not just once). Skipped for
--- IgnoreClamping frames (frameData opt-out) since those manage their own clamping.
---@param frame Frame
function onSizeUpdate(frame)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled or frameData.IgnoreClamping then return end
if frame:IsProtected() and InCombatLockdown() then
addToCombatLockdownQueue(onSizeUpdate, frame)
return
end
local clampDistance = 40
local clampWidth = (frame:GetWidth() or 0) - clampDistance
local clampHeight = (frame:GetHeight() or 0) - clampDistance
frame:SetClampRectInsets(clampWidth, -clampWidth, -clampHeight, clampHeight)
end
--- Secure-hooked to Blizzard's own UIPanelUpdateScaleForFit/UpdateScaleForFit (whichever exists on this
--- client version) -- the function Blizzard uses to auto-shrink certain panels to fit smaller screen
--- resolutions. That auto-shrink otherwise fights directly with a user's own saved/session scale, so this
--- reapplies our own scale immediately afterward to win that fight, the same way onShow() does.
---@param frame Frame
local function onUpdateScaleForFit(frame)
local frameData = frameRegistry[frame]
if not frameData or not frameData.storage or frameData.storage.disabled then return end
if InCombatLockdown() and frame:IsProtected() then
addToCombatLockdownQueue(onUpdateScaleForFit, frame)
return
end
local frameName = frameData.storage.frameName
local scaleStrategy = settings().saveScaleStrategy
if scaleStrategy == "permanent" and settings().scales[frameName] then
setFrameScale(frame, settings().scales[frameName])
elseif sessionScales[frameName] then
setFrameScale(frame, sessionScales[frameName])
end
end
--=====================================================================
-- Mouse wheel capture
--=====================================================================
--- Decides whether the full-screen arbitration frame (mouseWheelCaptureFrame, see
--- initMouseWheelCaptureFrame() below) should currently be intercepting the mouse wheel at all. Called on
--- every MODIFIER_STATE_CHANGED and every onEnter/onLeave. Only actually enables capture when: scaling is
--- turned on, the scale modifier is currently held, the mouse is over at least one registered frame, AND
--- (walking every frame currently under the cursor, topmost-first via GetMouseFoci) nothing ELSE under the
--- cursor already wants the wheel for itself (a scrollable list, edit box, etc.) or is forbidden/secret --
--- we bail out (defer) the instant we hit one of those, before ever reaching a frame we'd otherwise handle,
--- since GetMouseFoci returns frames in top-to-bottom (visual stacking) order. Plain clickable widgets
--- (buttons, tabs, item slots) are deliberately NOT treated as wheel-consumers here even though they can
--- receive mouse focus, since otherwise densely-buttoned windows (CharacterFrame, MerchantFrame, BankFrame)
--- would never be scalable except over their few genuinely blank spots.
function checkMouseWheelCapture()
if not mouseWheelCaptureFrame then return end
mouseWheelCaptureFrame:EnableMouseWheel(false)
if not settings().enableScaling then return end
if not isScaleModifierDown() or not next(mouseoverFrames) then return end
local foci = GetMouseFoci and GetMouseFoci() or {}
if not next(foci) then return end
for _, focusFrame in ipairs(foci) do
-- our title-bar handle -- not the base window -- is what's actually mouse-enabled and shows up here,
-- so resolve the real registered window through it before consulting frameRegistry/mouseoverFrames.
local frame = moveHandles[focusFrame] and focusFrame:GetParent() or focusFrame
local frameData = frameRegistry[frame]
local shouldHandle = frameData and not frameData.IgnoreMouseWheel
if
not shouldHandle
and (
focusFrame:IsForbidden()
or (focusFrame.HasSecretValues and focusFrame:HasSecretValues())
or (not moveHandles[focusFrame] and focusFrame:IsMouseWheelEnabled())
)
then
-- something that actually wants the wheel (scroll list, edit box, etc.) is in the way; defer to it.
-- plain clickable widgets (buttons, tabs, item slots) don't consume wheel input, so they shouldn't
-- block scaling -- otherwise densely-buttoned windows (CharacterFrame, MerchantFrame, BankFrame,
-- etc.) would never be scalable except over their few blank spots.
return
end
if shouldHandle and mouseoverFrames[frame] then
mouseWheelCaptureFrame:EnableMouseWheel(true)
return
end
end
end
--- One-time setup of the full-screen, TOOLTIP-strata (i.e. above virtually everything) arbitration frame
--- used to implement scroll-to-scale: since a normal registered frame's own EnableMouseWheel would
--- unconditionally steal the wheel from whatever's under the cursor, we instead leave every window's own
--- wheel handling untouched and only turn ON this separate full-screen frame's own wheel handling for the
--- brief moments checkMouseWheelCapture() decides scaling should actually happen -- otherwise it stays
--- disabled and every wheel event passes through to whatever's normally below it, completely unaffected.
local function initMouseWheelCaptureFrame()
mouseWheelCaptureFrame = CreateFrame("Frame", "SlackHacksMovableWindowsMouseWheelCapture")
mouseWheelCaptureFrame:SetPoint("TOPLEFT")
mouseWheelCaptureFrame:SetPoint("BOTTOMRIGHT")
mouseWheelCaptureFrame:SetScript("OnEvent", checkMouseWheelCapture)
mouseWheelCaptureFrame:RegisterEvent("MODIFIER_STATE_CHANGED")
mouseWheelCaptureFrame:SetScript("OnMouseWheel", function(_, delta)
for _, focusFrame in ipairs(GetMouseFoci()) do
local frame = moveHandles[focusFrame] and focusFrame:GetParent() or focusFrame
local frameData = frameRegistry[frame]
if frameData and not frameData.IgnoreMouseWheel and mouseoverFrames[frame] then
onMouseWheel(frame, delta)
return
end
end
end)
mouseWheelCaptureFrame:SetFrameStrata("TOOLTIP")
mouseWheelCaptureFrame:SetFrameLevel(9999)
mouseWheelCaptureFrame:Show()
mouseWheelCaptureFrame:EnableMouseWheel(false)
end
--=====================================================================
-- Move handles (for protected frames) & frame processing
--=====================================================================
--- Hooks `script` on `frame` via SecureHookScript, but only if the frame's template actually supports that
--- script at all (HasScript) -- some Blizzard frames don't define e.g. OnHide, and hooking a nonexistent
--- script errors instead of silently no-oping.
---@param frame Frame
---@param script string - Script name, e.g. "OnShow".
---@param handler function
local function hookScript(frame, script, handler)
if frame:HasScript(script) then
module:SecureHookScript(frame, script, handler)
end
end
--- Creates the small, unprotected drag-bar overlay used to make a (possibly protected) frame movable and
--- wheel-scalable without ever calling a protected method ourselves. It inherits Blizzard's own
--- "PanelDragBarTemplate" so the actual StartMoving()/StopMovingOrSizing() calls always originate from
--- Blizzard's own template code reacting to a real native drag gesture (see startMoving()/stopMoving()
--- above for how we arm/disarm that), never from our own insecure OnMouseDown/OnDragStart -- this is what
--- lets protected frames (CharacterFrame, BankFrame, etc.) be dragged at all outside of combat lockdown,
--- since a plain insecure StartMoving() call on a protected frame would be blocked.
--- Deliberately sized to only the frame's TOP title-bar strip (not the whole window), and reparented from
--- its creation parent (rootFrame -- needed so its frame level is computed relative to the actual root
--- window, not a possibly-detached subframe) onto the real target frame, positioned via SetPoint so it
--- tracks the frame's own top edge. `titleBarRaise` extends that hit region upward past the frame's
--- technical top-left corner for windows whose visible title/banner artwork bleeds above it (e.g.
--- AchievementFrame). Also owns the scroll-to-scale hover region (onEnter/onLeave) for the same reason:
--- scaling should only engage from the title bar, not anywhere on the window body -- unless
--- `ignoreMouseWheel` opts the frame out of wheel scaling entirely.
---@param frame Frame - The frame this handle should visually track and control.
---@param rootFrame Frame - The root registered frame in this frame's chain (used only to compute frame level).
---@param titleBarHeight number - Height of the drag-bar hit region.
---@param titleBarRaise number - Extra pixels to extend the hit region upward past the frame's own top edge.
---@param ignoreMouseWheel boolean? - If true, skip wiring up scroll-to-scale hover tracking for this handle.
---@return Frame handle
local function makeMoveHandle(frame, rootFrame, titleBarHeight, titleBarRaise, ignoreMouseWheel)
local handle = CreateFrame("Frame", nil, rootFrame, "PanelDragBarTemplate")
handle:SetParent(frame)
handle:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, titleBarRaise)
handle:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, titleBarRaise)
handle:SetHeight(titleBarHeight + titleBarRaise)
handle:SetFrameLevel(frame:GetFrameLevel() + TITLE_BAR_HANDLE_LEVEL_OFFSET)
-- SetPropagateMouseMotion/Clicks are protected once the handle is parented under a protected frame
-- (e.g. CharacterFrame) -- guard with IsProtected()+pcall so it just silently no-ops there instead of
-- throwing ADDON_ACTION_BLOCKED; harmless to skip, it only affects click/tooltip passthrough under the
-- handle's own small title-bar strip.
if not frame:IsProtected() then
pcall(handle.SetPropagateMouseMotion, handle, true)
pcall(handle.SetPropagateMouseClicks, handle, true)
end
handle.onDragStartCallback = function() return false end
handle:HookScript("OnMouseDown", onMouseDown)
handle:HookScript("OnMouseUp", onMouseUp)
handle:HookScript("OnDragStop", function(self) onMouseUp(self, "LeftButton") end)
if not ignoreMouseWheel then
handle:EnableMouseWheel(true)
handle:HookScript("OnEnter", function() onEnter(frame) end)
handle:HookScript("OnLeave", function() onLeave(frame) end)
if handle:IsMouseOver() then
RunNextFrame(function()
if handle:IsMouseOver() then onEnter(frame) end
end)