-
Notifications
You must be signed in to change notification settings - Fork 23
Expand file tree
/
Copy pathEvents.lua
More file actions
768 lines (696 loc) · 31.3 KB
/
Copy pathEvents.lua
File metadata and controls
768 lines (696 loc) · 31.3 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
local addonName, DF = ...
-- Get module namespace
local CC = DF.ClickCast
-- EVENT HANDLING
-- ============================================================
function CC:RegisterEvents()
local eventFrame = CreateFrame("Frame")
self.eventFrame = eventFrame
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
-- Unit-filtered to "player". This event carries a unit and fires for every
-- party/raid member, and the handler runs CheckLoadoutProfileSwitch plus a
-- full ApplyBindings -- the ~500-frame batched sweep. Unfiltered, every
-- raid member respeccing cost a full sweep and the hover-bind window that
-- comes with it, for a profile decision that only ever concerns us.
eventFrame:RegisterUnitEvent("PLAYER_SPECIALIZATION_CHANGED", "player")
-- Roster churn is when frames are created and retired; see the handler.
eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("PLAYER_LEVEL_UP")
eventFrame:RegisterEvent("PLAYER_EQUIPMENT_CHANGED")
-- Talent/Loadout events for profile auto-switching
eventFrame:RegisterEvent("TRAIT_CONFIG_UPDATED")
eventFrame:RegisterEvent("TRAIT_CONFIG_CREATED")
eventFrame:RegisterEvent("ACTIVE_PLAYER_SPECIALIZATION_CHANGED")
eventFrame:RegisterEvent("ACTIVE_COMBAT_CONFIG_CHANGED") -- Fires when loadout is switched
-- Events for dynamic frames (boss/arena)
eventFrame:RegisterEvent("ARENA_PREP_OPPONENT_SPECIALIZATIONS")
eventFrame:RegisterEvent("INSTANCE_ENCOUNTER_ENGAGE_UNIT")
-- Nameplate events for click-casting on nameplates
eventFrame:RegisterEvent("NAME_PLATE_UNIT_ADDED")
eventFrame:RegisterEvent("NAME_PLATE_UNIT_REMOVED")
-- Spell data arrival — one of the triggers that re-runs a cold-start
-- profile check (see CC:ResolveColdStartProfile)
eventFrame:RegisterEvent("SPELLS_CHANGED")
-- Player housing can invalidate secure wraps on unit frames
-- (field case 2026-07-20: hover keybinds dead after a housing session,
-- wraps no longer executing). The repair re-wraps, so run it on every
-- editor-mode change. pcall'd: the event only exists on clients with
-- housing.
pcall(function() eventFrame:RegisterEvent("HOUSE_EDITOR_MODE_CHANGED") end)
eventFrame:SetScript("OnEvent", function(_, event, ...)
if event == "PLAYER_REGEN_ENABLED" then
-- Out of combat - process pending operations
CC:OnCombatEnd()
elseif event == "PLAYER_REGEN_DISABLED" then
-- Entered combat (no action needed)
elseif event == "PLAYER_SPECIALIZATION_CHANGED" or event == "ACTIVE_PLAYER_SPECIALIZATION_CHANGED" then
-- Spec changed - check for profile switch
CC:OnSpecChanged()
-- Cold-start resolve: a loadout check that ran before
-- GetSpecialization() resolved could not pick a profile — run it now
CC:ResolveColdStartProfile("spec-resolved")
elseif event == "SPELLS_CHANGED" then
-- Spell data arrived/changed — no-op unless a check is outstanding
CC:ResolveColdStartProfile("spells-changed")
elseif event == "TRAIT_CONFIG_UPDATED" or event == "TRAIT_CONFIG_CREATED" or event == "ACTIVE_COMBAT_CONFIG_CHANGED" then
-- Loadout/talent changed - check for profile switch and reapply bindings (with debounce)
if not InCombatLockdown() then
-- Debounce: wait before checking to ensure API data is ready
CC:DeferAfter("loadoutCheck", 0.5, function()
CC:CheckLoadoutProfileSwitch()
-- Reapply bindings to pick up spell overrides from talent changes
CC:ApplyBindings()
-- Also refresh UI in case talents changed
CC:DeferAfter("uiRefresh", 0.3, function()
CC:RefreshClickCastingUI()
end)
end)
else
CC:Defer("loadoutCheck")
CC:Defer("bindingRefresh")
end
elseif event == "PLAYER_LEVEL_UP" then
-- Level up - may have learned new spells, reapply bindings
if not InCombatLockdown() then
CC:ApplyBindings()
CC:DeferAfter("uiRefresh", 0.2, function()
CC:RefreshClickCastingUI()
end)
else
CC:Defer("bindingRefresh")
end
elseif event == "PLAYER_EQUIPMENT_CHANGED" then
-- Equipment changed - refresh items tab if visible
if CC.activeTab == "items" then
CC:RefreshSpellGrid()
end
elseif event == "PLAYER_ENTERING_WORLD" then
CC:ScheduleZoneSettle()
elseif event == "GROUP_ROSTER_UPDATE" then
-- Roster churn creates and retires frames -- including third-party
-- ones, whose only route in is the ClickCastFrames table that cannot
-- report a retry (see ReconcileClickCastFrames). This module used to
-- register no roster event at all and relied entirely on the
-- SecureUnitButton_OnLoad hook plus a login-only scan, which is how a
-- party->raid change could leave frames dead until a /reload.
if InCombatLockdown() then
CC:Defer("bindingRefresh")
else
-- Keyed and delayed, NOT immediate. ApplyBindings cancels any
-- in-flight batch walker and re-wipes the header's override
-- bindings before restarting from batch 0. Calling it once per
-- roster event means a burst -- a raid forming, mass join/leave,
-- role assignment, zone-in -- can restart the sweep faster than
-- it completes, and every restart kills the live hover binds
-- again. That is the same dead-key class this whole change set
-- exists to close, reached through a new trigger.
--
-- Nothing is lost by waiting: frames created during the roster
-- event register through EnsureRegistered and the ClickCastFrames
-- metatable, not through ApplyBindings, which only refreshes
-- bindings on frames already registered. Same keyed-DeferAfter
-- shape as zoneSettle, so a burst coalesces into one pass.
CC:DeferAfter("rosterSettle", 0.5, function()
CC:ReconcileClickCastFrames()
CC:ApplyBindings()
end)
end
elseif event == "ARENA_PREP_OPPONENT_SPECIALIZATIONS" then
-- Arena frames should now exist
-- Belt: never enter an arena on an unresolved cold-start profile
CC:ResolveColdStartProfile("arena-prep")
CC:OnArenaPrep()
elseif event == "INSTANCE_ENCOUNTER_ENGAGE_UNIT" then
-- Boss frames should now exist
CC:OnBossEngage()
elseif event == "NAME_PLATE_UNIT_ADDED" then
-- A nameplate was added
local unitToken = ...
CC:OnNamePlateAdded(unitToken)
elseif event == "NAME_PLATE_UNIT_REMOVED" then
-- A nameplate was removed
local unitToken = ...
CC:OnNamePlateRemoved(unitToken)
elseif event == "HOUSE_EDITOR_MODE_CHANGED" then
-- Housing mode transitions can kill secure wraps; the repair
-- re-wraps every frame (self-defers in combat, cooldown-limited)
CC:RequestBindingRepair("housing-mode")
end
end)
-- Our own PLAYER_ENTERING_WORLD registration happens INSIDE the dispatch of
-- that very event (Initialize is driven from a PEW handler elsewhere, which
-- calls InitializeSecureFrames -> RegisterEvents), so this frame never
-- receives the login PEW. Everything in the settle pass was therefore absent
-- at login and first ran on the next loading screen: nameplate registration,
-- the zone-in binding repair, and the cold-start profile resolve that exists
-- specifically for "none of my binds work in my first arena of the day".
-- Kick it once here; the key makes it idempotent against a real PEW landing
-- immediately after.
self:ScheduleZoneSettle()
end
-- The post-loading-screen settle pass, factored out so it can also be kicked
-- once at init (see the note at the end of RegisterEvents). Keyed: back-to-back
-- loading screens reuse one pending pass rather than stacking several.
function CC:ScheduleZoneSettle()
CC:DeferAfter("zoneSettle", 0.5, function()
-- Run one-time migration to convert bindings to root spells
CC:MigrateBindingsToRootSpells()
CC:RegisterAllFrames()
-- Register Blizzard frames if any binding needs them
if CC:AnyBindingNeedsBlizzardFrames() then
CC:RegisterBlizzardFrames()
end
-- Register all currently visible nameplates
CC:RegisterAllNameplates()
-- Apply hovercast bindings
CC:ApplyGlobalBindings()
-- Self-heal (bug #976): reset restricted-env hover tracking and
-- rebuild keyboard binding snippets after every loading screen,
-- so a broken hover-bind state never survives a zone change
CC:RunBindingRepair("zone-in", true)
-- Cold-start resolve: if the login check ran before spec data
-- was available, no profile could be picked — re-run it on the
-- first loading screen so the right profile is active BEFORE
-- the first arena/dungeon of the session, not only after a
-- /reload
CC:ResolveColdStartProfile("zone-in")
-- Check for loadout-based profile on initial load. Keyed so
-- back-to-back loading screens reuse one pending pass, and
-- called unconditionally: CheckLoadoutProfileSwitch defers
-- itself onto the "loadoutCheck" queue job in lockdown, so a
-- guard here would only duplicate that one decision point.
-- (The original call-site guard DROPPED the check outright
-- when zone-in+1s landed in combat — the arena-load race.)
--
-- Distinct timer key from the TRAIT_CONFIG_UPDATED settle above:
-- that callback also runs ApplyBindings and a UI refresh, so
-- sharing a key let a loading screen cancel a pending talent
-- reapply and strand every frame on the old loadout's macros.
CC:DeferAfter("zoneLoadoutCheck", 1, function()
CC:CheckLoadoutProfileSwitch()
end)
end)
end
-- ============================================================
-- DEFERRED WORK QUEUE
-- ============================================================
-- Click casting cannot touch secure state in combat, so work blocked by
-- combat lockdown has to be replayed afterwards. This used to be ten
-- separate self.needsX / self.pendingX flags, each with its own set-site and
-- its own hand-written drain line in OnCombatEnd. Adding a deferral meant
-- remembering to add a matching drain; forgetting silently dropped the work
-- for the rest of the session (that is how the arena cold-start bug and the
-- keyboard-refresh drop both happened).
--
-- Now there is one queue. Register the job here, call CC:Defer("job"), and
-- the drain is automatic and ordered. Three job kinds:
-- flag - "do this thing later", no payload, dedupes to a single run
-- value - carries one value (a profile name, a repair reason); policy
-- "first" keeps the earliest, "last" keeps the most recent
-- set - accumulates a set of items (frames) and runs once over all
--
-- A job's run() returns true to request a UI refresh once the drain settles.
-- DRAIN_ORDER is load-bearing: it reproduces the exact sequence the old
-- OnCombatEnd used. Do not reorder without checking that dependency chain
-- (profile switch must precede binding work; registration must precede the
-- binding refresh that walks registered frames).
local DRAIN_ORDER = {
-- First: the OnEnter snippet reads dfClickCastEnabled to decide whether to
-- run at all, so a stale value makes everything below it pointless.
"headerEnabled",
"profileSwitch",
"loadoutCheck",
"register",
"unregister",
"fullRegistration",
"reassert",
"bindingRepair",
"bindingRefresh",
"blizzardRegister",
"blizzardUnregister",
"keyboardRefresh",
}
local DEFERRED_JOBS = {
-- Carries the enabled state SetEnabled could not write during lockdown.
-- "last" wins: if the user toggled twice in one fight, the final state is
-- the one they meant. Stored as a string because Defer treats a nil payload
-- as "nothing queued", which would silently drop a toggle to OFF.
headerEnabled = {
kind = "value", policy = "last",
run = function(self, state)
if self.header then
self.header:SetAttribute("dfClickCastEnabled", state == "on")
end
end,
},
profileSwitch = {
kind = "value", policy = "last",
run = function(self, profileName)
if self:SetActiveProfile(profileName) then
self:ApplyBindings()
return true
end
end,
},
loadoutCheck = {
kind = "flag",
run = function(self)
self:CheckLoadoutProfileSwitch()
return true
end,
},
register = {
kind = "set",
run = function(self, frames)
for frame in pairs(frames) do
self:RegisterFrame(frame)
end
end,
},
unregister = {
kind = "set",
run = function(self, frames)
for frame in pairs(frames) do
self:UnregisterFrame(frame)
end
end,
},
reassert = {
-- Blizzard's SecureUnitButton_OnLoad reset these frames' click
-- registration (see the hook in InitializeSecureFrames) — it runs on
-- every CompactUnitFrame_SetUnit roster shuffle and stomps
-- RegisterForClicks back to AnyUp plus the wildcard click actions.
-- Re-apply our bindings on exactly the frames that were touched.
kind = "set",
run = function(self, frames)
for frame in pairs(frames) do
if self.registeredFrames and self.registeredFrames[frame] then
self:ApplyBindingsToFrameUnified(frame)
end
end
end,
},
fullRegistration = {
kind = "flag",
run = function(self) self:RegisterAllFrames() end,
},
bindingRepair = {
-- first-write-wins: the earliest reason is the one that diagnosed the
-- breakage; later requeues during the same combat are the same repair
kind = "value", policy = "first",
-- forced: CombatGuard cannot carry RunBindingRepair's `force` argument
-- into the queue, so a repair that was explicitly unconditional (the
-- zone-in self-heal passes force=true) came back through the drain as a
-- cooldown-gated one and could be dropped by any repair that happened to
-- run in the 5s before combat ended. This job runs at most once per
-- drain, so the cooldown buys nothing here and only loses repairs.
run = function(self, reason) self:RunBindingRepair(reason, true) end,
},
bindingRefresh = {
kind = "flag",
run = function(self)
self:ApplyBindings()
return true
end,
},
blizzardRegister = {
kind = "flag",
run = function(self) self:RegisterBlizzardFrames() end,
},
blizzardUnregister = {
kind = "flag",
run = function(self) self:UnregisterBlizzardFrames() end,
},
keyboardRefresh = {
kind = "flag",
run = function(self) self:RefreshKeyboardBindings() end,
},
}
-- Queue work for the next time we are out of combat.
-- Safe to call repeatedly: flags dedupe, values follow their policy, sets accumulate.
-- Returns true when the job was queued. CombatGuard relies on that: a typo'd
-- job name must not read as "queued", or the caller aborts and the work is lost
-- with only an INFO line to show for it -- and INFO is exactly what the log's
-- eviction policy discards first.
-- Jobs that mean the opposite of each other. Queueing one must CANCEL the other
-- for that payload, because the queue is otherwise order-blind: both entries
-- survive, and DRAIN_ORDER alone decides the outcome -- so the later intent
-- loses whenever it happens to sit earlier in the order.
--
-- The case that bites is nameplates. Blizzard recycles a fixed pool of plate
-- frames, so within one fight the SAME frame object is legitimately removed and
-- re-added for different units. Both sets end up holding it, `register` drains
-- at slot 3 and `unregister` at slot 4, and the drain tears down a plate that is
-- on screen showing a live unit. The Blizzard-frame pair has the same shape from
-- a user toggling the option twice in combat: whatever they picked last, off
-- wins.
local OPPOSED_JOBS = {
register = "unregister",
unregister = "register",
blizzardRegister = "blizzardUnregister",
blizzardUnregister = "blizzardRegister",
}
function CC:Defer(job, payload)
local def = DEFERRED_JOBS[job]
if not def then
DF:DebugError("CLICK", "Defer: unknown job '%s' — work dropped", tostring(job))
return false
end
self.deferred = self.deferred or {}
-- Latest intent wins: drop the contradicting entry rather than letting
-- DRAIN_ORDER arbitrate between two things the caller never asked for both of.
local opposite = OPPOSED_JOBS[job]
if opposite and self.deferred[opposite] ~= nil then
local other = self.deferred[opposite]
if type(other) == "table" then
if payload ~= nil and other[payload] then
other[payload] = nil
DF:Debug("CLICK", "Defer: '%s' cancels queued '%s' for the same frame", job, opposite)
end
else
self.deferred[opposite] = nil
DF:Debug("CLICK", "Defer: '%s' cancels queued '%s'", job, opposite)
end
end
if def.kind == "set" then
local set = self.deferred[job]
if type(set) ~= "table" then
set = {}
self.deferred[job] = set
end
if payload ~= nil then set[payload] = true end
elseif def.kind == "value" then
if def.policy == "first" and self.deferred[job] ~= nil then
return true -- keep the earliest value; still queued
end
self.deferred[job] = payload
else
self.deferred[job] = true
end
return true
end
-- Run queued work. Pass a job name to drain only that job (used at init, where
-- only frame registration is safe to replay); omit it to drain everything.
function CC:DrainDeferred(onlyJob)
if InCombatLockdown() then return end
local queue = self.deferred
if not queue then return end
local needsUIRefresh = false
local ran -- diagnostic: which jobs actually ran this drain
for _, job in ipairs(DRAIN_ORDER) do
if not onlyJob or onlyJob == job then
local payload = queue[job]
if payload ~= nil then
-- clear before running: a job that re-defers itself (a repair
-- that finds more work) must queue for the NEXT drain, not be
-- wiped by this one
queue[job] = nil
ran = ran and (ran .. "," .. job) or job
-- pcall'd: the payload is already gone, so an error inside one
-- job must not (a) abandon it silently -- the old flag drains
-- cleared AFTER the work, so a failure retried next combat end
-- -- or (b) skip every job after it in DRAIN_ORDER. Report it
-- and carry on; the queue keeps draining.
local ok, wantsRefresh = pcall(DEFERRED_JOBS[job].run, self, payload)
if not ok then
DF:DebugError("CLICK", "Deferred job '%s' errored during drain: %s",
job, tostring(wantsRefresh))
elseif wantsRefresh then
needsUIRefresh = true
end
end
end
end
-- Surface what recovered at combat end / init. Previously silent, so a log
-- showed "queued for combat end" with no confirmation the work ever ran.
if ran then
DF:Debug("CLICK", "DrainDeferred ran: %s", ran)
end
if next(queue) == nil then
self.deferred = nil
end
-- Refresh UI if needed (after a short delay for everything to settle)
if needsUIRefresh then
self:DeferAfter("uiRefresh", 0.2, function()
CC:RefreshClickCastingUI()
end)
end
end
-- ============================================================
-- KEYED SETTLE TIMERS
-- ============================================================
-- Click casting settles state after events (loading screens, spec changes,
-- arena prep) using short timers. Several of those events fire in bursts, and
-- with bare C_Timer.After each burst stacked another timer -- so a callback
-- could run with state captured before the previous one had finished, and
-- retry chains could fork into several concurrent chains.
--
-- DeferAfter keys each timer: scheduling the same key again cancels the
-- pending one, so there is always at most one run in flight per key.
function CC:DeferAfter(key, delay, fn)
self.timers = self.timers or {}
local existing = self.timers[key]
if existing then existing:Cancel() end
self.timers[key] = C_Timer.NewTimer(delay, function()
if CC.timers then CC.timers[key] = nil end
fn()
end)
end
-- Guard for functions that must not touch secure state in combat.
-- Returns true if the caller should abort; the work is queued as `job` so it
-- cannot be silently lost. Defer at the point of blocking rather than trusting
-- a caller further up the stack to have set a flag.
function CC:CombatGuard(job, payload)
if not InCombatLockdown() then return false end
-- Always abort in combat, even if Defer rejected the job name. "Fail loudly"
-- on a bad name would mean letting the caller go on to touch secure state
-- during lockdown, which errors -- strictly worse than dropping the work.
-- Defer already logs an unknown job as an error, so it is not silent.
self:Defer(job, payload)
return true
end
function CC:OnCombatEnd()
self:DrainDeferred()
end
function CC:OnSpecChanged()
-- Spec changed - check for profile switch based on new spec/loadout
if not InCombatLockdown() then
self:CheckLoadoutProfileSwitch()
self:ApplyBindings()
-- Refresh UI after a short delay to ensure spell data is ready
self:DeferAfter("uiRefresh", 0.3, function()
CC:RefreshClickCastingUI()
end)
else
self:Defer("loadoutCheck")
self:Defer("bindingRefresh")
end
end
function CC:OnArenaPrep()
if self.db.options.globalEnabled then
-- Arena frames should now exist, try to register.
-- Keyed: ARENA_PREP fires once per opponent, so this would otherwise
-- schedule several identical registration passes.
self:DeferAfter("dynamicFrameRegister", 0.1, function()
if not CC:CombatGuard("blizzardRegister") then
CC:RegisterBlizzardFrames()
end
end)
end
end
function CC:OnBossEngage()
if self.db.options.globalEnabled then
-- Boss frames should now exist.
-- Keyed: INSTANCE_ENCOUNTER_ENGAGE_UNIT fires repeatedly during an
-- encounter, so this would otherwise stack a timer per fire.
self:DeferAfter("dynamicFrameRegister", 0.1, function()
if not CC:CombatGuard("blizzardRegister") then
CC:RegisterBlizzardFrames()
end
end)
end
end
-- ============================================================
-- NAMEPLATE HANDLING
-- ============================================================
-- Track registered nameplates
CC.registeredNameplates = CC.registeredNameplates or {}
-- Called when a nameplate is added
function CC:OnNamePlateAdded(unitToken)
if not self.db or not self.db.enabled then return end
if not self.db.options.globalEnabled then return end
-- Get the nameplate frame
local nameplate = C_NamePlate.GetNamePlateForUnit(unitToken)
if not nameplate then return end
-- Debug output
if self.db.options.debugBindings then
local name = UnitName(unitToken) or "Unknown"
print("|cff33cc66DF Nameplate:|r Added for " .. name .. " (" .. unitToken .. ")")
end
-- Get the actual clickable button from the nameplate
-- Different nameplate addons structure this differently
local clickableFrame = self:GetNameplateClickableFrame(nameplate, unitToken)
if clickableFrame then
-- Mark as a nameplate frame
clickableFrame.dfIsNameplate = true
clickableFrame.dfNameplateUnit = unitToken
-- Track it
self.registeredNameplates[unitToken] = clickableFrame
-- Register for click-casting
if not InCombatLockdown() then
self:RegisterFrame(clickableFrame)
if self.db.options.debugBindings then
local frameName = clickableFrame:GetName() or "unnamed"
print("|cff33cc66DF Nameplate:|r Registered frame: " .. frameName)
end
else
-- Queue for after combat
self:Defer("register", clickableFrame)
end
else
if self.db.options.debugBindings then
print("|cffff6666DF Nameplate:|r Could not find clickable frame for " .. unitToken)
end
end
end
-- Called when a nameplate is removed
function CC:OnNamePlateRemoved(unitToken)
local frame = self.registeredNameplates[unitToken]
if frame then
if self.db.options.debugBindings then
print("|cff33cc66DF Nameplate:|r Removed for " .. unitToken)
end
-- Unregister from click-casting
if not InCombatLockdown() then
self:UnregisterFrame(frame)
else
-- Queue for after combat
self:Defer("unregister", frame)
end
self.registeredNameplates[unitToken] = nil
end
end
-- Get the clickable frame from a nameplate
-- This handles different nameplate addon structures
function CC:GetNameplateClickableFrame(nameplate, unitToken)
if not nameplate then return nil end
-- Debug helper
local function debugFrame(label, frame)
if self.db.options.debugBindings and frame then
local name = frame:GetName() or "unnamed"
local objType = frame:GetObjectType()
local isButton = frame:IsObjectType("Button")
local hasRegister = frame.RegisterForClicks ~= nil
local unit = frame:GetAttribute("unit") or frame.unit
print(" [Debug " .. label .. "] " .. name .. " (" .. objType .. ") isButton=" .. tostring(isButton) .. " hasRegister=" .. tostring(hasRegister) .. " unit=" .. tostring(unit))
end
end
-- Try to find the UnitFrame child (Blizzard default structure)
local unitFrame = nameplate.UnitFrame
if unitFrame then
debugFrame("UnitFrame", unitFrame)
-- Check if it's a Button or has RegisterForClicks
if unitFrame:IsObjectType("Button") or unitFrame.RegisterForClicks then
local unit = unitFrame:GetAttribute("unit") or unitFrame.unit
if unit then
return unitFrame
end
end
end
-- Try the nameplate itself
debugFrame("nameplate", nameplate)
if nameplate:IsObjectType("Button") or nameplate.RegisterForClicks then
local unit = nameplate:GetAttribute("unit")
if unit then
return nameplate
end
end
-- Try common nameplate addon patterns
-- Plater
if nameplate.unitFrame then
debugFrame("Plater unitFrame", nameplate.unitFrame)
if nameplate.unitFrame:IsObjectType("Button") or nameplate.unitFrame.RegisterForClicks then
return nameplate.unitFrame
end
end
-- Plater alternate structure
if nameplate.PlaterFrame then
debugFrame("PlaterFrame", nameplate.PlaterFrame)
return nameplate.PlaterFrame
end
-- KuiNameplates
if nameplate.kui then
local kuiFrame = nameplate.kui
debugFrame("KuiFrame", kuiFrame)
if kuiFrame.HealthBar then
return kuiFrame
end
end
-- TidyPlates / ThreatPlates
if nameplate.TPFrame then
debugFrame("TPFrame", nameplate.TPFrame)
return nameplate.TPFrame
end
-- NeatPlates
if nameplate.carrier then
debugFrame("NeatPlates carrier", nameplate.carrier)
return nameplate.carrier
end
-- Fallback: search all children for a Button with unit attribute
for _, child in ipairs({nameplate:GetChildren()}) do
if child:IsObjectType("Button") then
debugFrame("Child Button", child)
local childUnit = child:GetAttribute("unit") or child.unit
if childUnit then
return child
end
end
end
-- Last resort: search for any frame with RegisterForClicks
for _, child in ipairs({nameplate:GetChildren()}) do
if child.RegisterForClicks then
debugFrame("Child with RegisterForClicks", child)
local childUnit = child:GetAttribute("unit") or child.unit
if childUnit or not InCombatLockdown() then
-- Set unit if missing
if not child:GetAttribute("unit") and not InCombatLockdown() then
child:SetAttribute("unit", unitToken)
end
return child
end
end
end
-- Very last resort: if the nameplate's UnitFrame exists but isn't a Button,
-- we can still try to use it with SecureActionButton behavior
if unitFrame and not InCombatLockdown() then
debugFrame("Fallback UnitFrame", unitFrame)
-- Try to set it up for click-casting
if not unitFrame:GetAttribute("unit") then
unitFrame:SetAttribute("unit", unitToken)
end
return unitFrame
end
if self.db.options.debugBindings then
print(" [Debug] No suitable clickable frame found for nameplate")
end
return nil
end
-- Register all currently visible nameplates
function CC:RegisterAllNameplates()
if not self.db or not self.db.enabled then return end
if not self.db.options.globalEnabled then return end
-- Get all visible nameplates
local nameplates = C_NamePlate.GetNamePlates()
if self.db.options.debugBindings then
print("|cff33cc66DF Nameplate:|r Registering " .. #nameplates .. " visible nameplates")
end
for _, nameplate in ipairs(nameplates) do
local unitToken = nameplate.namePlateUnitToken
if unitToken then
self:OnNamePlateAdded(unitToken)
end
end
end
-- ============================================================