Repository navigation
Expand file tree
/
Copy pathArcUI_PT_Core.lua
More file actions
3680 lines (3508 loc) · 172 KB
/
Copy pathArcUI_PT_Core.lua
File metadata and controls
3680 lines (3508 loc) · 172 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
-- ArcUI_PT_Core.lua
-- ProcTracker: icon widget factory, per-deck options panel, /pt slash command.
-- No detection logic here. Decks register via PT.RegisterDeck().
-- No pcall. Zero polling.
-- PRIVATE namespace, NOT a global. This was `PT = {}` -- a bare two-letter global --
-- so any other addon or WeakAura that assigned PT replaced this table, and every
-- module's PT.<field> read went nil (the MSW.lua:141 error storm users reported).
-- WoW hands every file in this addon the SAME table through `...`, and each file
-- holds it as a LOCAL captured at load, so an outside PT can no longer reach us.
-- Every ArcUI_PT_*.lua file must therefore start with `local ADDON, PT = ...`.
local ADDON, PT = ...
_G.ArcUI_PT = PT -- non-colliding handle, so /run ArcUI_PT.foo still works
local InitMinimapButton -- forward declare
local BuildOptionsPanel -- forward declare
local LDB, LDBIcon -- forward declare (real assignment near minimap section)
-- ── Registry ──────────────────────────────────────────────────────────────────
-- Each entry: { id, name, deckSize, procs, defaultIcon, widget, optPanel,
-- GetDeckPos, GetProcs, OnReset, OnEnable, OnDisable }
local registry = {} -- ordered list
local registryMap = {} -- id → entry
-- ── SavedVariables helpers ────────────────────────────────────────────────────
local DB_NAME = "ArcUI_ProcTrackerDB"
local function GetDB()
ArcUI_ProcTrackerDB = ArcUI_ProcTrackerDB or {}
return ArcUI_ProcTrackerDB
end
-- Has WoW actually handed us our SavedVariables yet?
--
-- Do NOT test `if ArcUI_ProcTrackerDB then` for this. GetDB() FABRICATES that
-- global on first touch, and decks touch it before registering: DWDeck's
-- registration table evaluates ForceCDMSetting() -> PT.GetIconDB("dw") ->
-- GetDB() while building the table it passes to PT.RegisterDeck, so the global
-- exists but is EMPTY by the time the build guards run. Both guards then see a
-- truthy value and build the icon and bar out of ICON_DEFAULTS / BAR_DEFAULTS,
-- i.e. at the DEFAULT position. WoW later replaces the global with the real
-- saved table, but nothing ever re-anchors an existing widget -- SetPoint on the
-- bar happens only in BuildBarWidget and the drag/options handlers -- so it
-- stays wherever the defaults put it. That is the "my bar reset its position on
-- reload" bug, and the `not entry.widget` duplicate guard added in 1.1.3 is what
-- stopped the later correct-position rebuild from papering over it.
--
-- Gate every widget build on this flag instead.
local savedVarsLoaded = false
function PT.SavedVarsLoaded() return savedVarsLoaded end
local ICON_DEFAULTS = {
posX=0, posY=180, iconW=48, iconH=48, iconScale=1.0, frameStrata="HIGH", frameLevel=5, showViolations=false, desaturateEmpty=false, violOffX=0, violOffY=-20, violSize=12, violR=1, violG=0.2, violB=0.2,
deckOffX=0, deckOffY=0, deckSize=19,
procOffX=0, procOffY=27, procSize=19,
countDown=true, procCountDown=true,
-- The three readouts are independent: each has its own Show toggle, font,
-- size, colour and position. Deck and proc default ON because they predate
-- the toggles and were always drawn; the chance text is new, so it is opt-in.
showDeckText=true, showProcText=true, showChanceText=false,
-- Anchor spec per text: "0" = this widget's own icon, "cdm:<id>" = a
-- Cooldown Manager icon, "action:<ids>" = an action bar button.
deckAnchor="0", procAnchor="0", chanceAnchor="0", violAnchor="0",
-- STATE VISUALS (timer entries). Two buckets, named for what the player
-- sees rather than ArcUI's readyState/cooldownState pair -- that naming is
-- FLIPPED for timers over there (running maps to readyState) and its own
-- skill calls it the number one source of confusion. Here: "ready" means
-- usable, "cd" means the internal cooldown is running.
rdyAlpha=1.0, rdyDesat=false, rdyTint=false, rdyTintR=1, rdyTintG=1, rdyTintB=1,
rdyGlow=false, rdyGlowStyle="pixel", rdyGlowR=0.95, rdyGlowG=0.95, rdyGlowB=0.32,
rdyGlowPad=0, rdyGlowX=0, rdyGlowY=0, rdyGlowSpeed=0.25,
rdyGlowLines=8, rdyGlowLength=0, rdyGlowThick=2, rdyGlowBorder=false,
rdyGlowParticles=4, rdyGlowScale=1, rdyGlowDuration=1,
cdAlpha=1.0, cdDesat=false, cdTint=false, cdTintR=1, cdTintG=1, cdTintB=1,
cdGlow=false, cdGlowStyle="pixel", cdGlowR=0.95, cdGlowG=0.95, cdGlowB=0.32,
cdGlowPad=0, cdGlowX=0, cdGlowY=0, cdGlowSpeed=0.25,
-- COOLDOWN ANIMATION (timer entries): the sweep the game draws over the
-- icon while the internal cooldown runs. Blizzard's own defaults, except
-- the edge line, which each entry can turn on for itself.
swipeShow=true, swipeR=0, swipeG=0, swipeB=0, swipeA=0.8,
swipeEdge=false, swipeNumbers=true, swipeBling=true, swipeReverse=false,
cdGlowLines=8, cdGlowLength=0, cdGlowThick=2, cdGlowBorder=false,
cdGlowParticles=4, cdGlowScale=1, cdGlowDuration=1,
chanceOffX=0, chanceOffY=-27, chanceSize=19,
chanceSpend=10, -- stacks the next spender is assumed to eat (Enh full stack)
chanceForecast="auto", -- Ele only: "auto" | "instant" | "blast"
chanceDecimals=true, -- show 4.9% rather than 5%
chanceColorMode="fixed", -- "fixed" | "procs" | "chance"
chanceR=1.0, chanceG=1.0, chanceB=1.0,
-- "chance" mode: below low = cold, at/above high = hot, between = mid
chanceLowPct=3, chanceHighPct=10,
chanceColdR=0.6, chanceColdG=0.6, chanceColdB=0.6,
chanceMidR=1.0, chanceMidG=0.82, chanceMidB=0.0,
chanceHotR=0.0, chanceHotG=1.0, chanceHotB=0.0,
-- "procs left" mode: ONE COLOUR PER PROC COUNT, not three fixed buckets.
-- Deck sizes differ (Doom Winds 3, Storm Unleashed 5, Tempest 2,
-- Soulburst 1), and every one of them is small enough to name each count
-- outright, which beats asking the user to pick thresholds over a range of
-- three values. Sparse: only counts the user has actually edited are
-- stored; the rest fall back to DefaultCountColor.
chanceCountColors=nil,
procSound="None", -- sound when a proc comes off this deck
procSoundEnabled=false, -- master switch: mutes without losing the picked sound
procSoundChannel="Master", -- which of WoW's volume sliders it rides
showDeckSuffix=false, showProcSuffix=false,
customIcon=nil,
emptyR=0.0, emptyG=1.0, emptyB=0.0,
halfR=1.0, halfG=0.82, halfB=0.0,
fullR=1.0, fullG=0.0, fullB=0.0,
deckR=1.0, deckG=1.0, deckB=1.0,
borderEnabled=true, borderThickness=1, borderInset=0,
borderUseClass=false,
borderR=0.0, borderG=0.0, borderB=0.0, borderA=1.0,
lockPosition=false,
textOnly=false,
textsUnlocked=false,
hideOOC=false, -- opt-in: hide this widget while out of combat
-- Fonts are PER TEXT (deckFont / procFont / violFont). nil = game default,
-- otherwise a LibSharedMedia font name.
}
-- ── Shared media ──────────────────────────────────────────────────────────────
-- LibSharedMedia is a single shared registry, so the font list here automatically
-- includes anything other addons have registered -- ArcUI's fonts show up without
-- ProcTracker needing to know ArcUI exists.
local LSM = LibStub and LibStub("LibSharedMedia-3.0", true)
local DEFAULT_FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF"
function PT.ResolveFont(name)
if name and LSM then
local path = LSM:Fetch("font", name, true)
if path then return path end
end
return DEFAULT_FONT
end
-- Applying a font can FAIL silently: SetFont with a path the client cannot load
-- leaves the FontString with no font at all, which renders as nothing. That is
-- what made text vanish when cycling fonts. Always verify with GetFont and fall
-- back to the game font, so a bad pick degrades instead of blanking the text.
function PT.SetFontSafe(fs, fontName, size, flags)
if not fs then return end
size = math.max(1, math.floor(tonumber(size) or 12))
flags = flags or "OUTLINE"
-- SetFont RETURNS a boolean; on failure the FontString silently KEEPS its
-- previous font. Checking GetFont() afterwards is useless because it hands
-- back that old font rather than nil, so a failed change looks identical to
-- a successful one and the text just never updates. Trust the return value.
local path = PT.ResolveFont(fontName)
if fs:SetFont(path, size, flags) == false then
-- The chosen font could not be loaded. Try it without the outline flag
-- (some TTFs only fail with flags), then fall back to the game font so
-- the text is never left blank.
if fs:SetFont(path, size, "") == false then
fs:SetFont(DEFAULT_FONT, size, flags)
end
end
-- Last resort: a FontString with no font at all renders nothing.
if not fs:GetFont() then
fs:SetFont(DEFAULT_FONT, size, flags)
end
end
-- Values table for an AceConfig select. Empty when LSM is missing, in which case
-- the option hides itself rather than showing a broken dropdown.
function PT.FontValues()
local t = {}
if LSM then
for _, name in ipairs(LSM:List("font")) do t[name] = name end
end
return t
end
function PT.HasSharedMedia() return LSM ~= nil end
-- Applies the icon's three fonts directly, with no dependence on the rest of
-- the redraw. Mirrors PT.ApplyBarFonts so a font pick always lands even if the
-- surrounding update path bails out for some other reason.
function PT.ApplyIconFonts(entry)
local w = entry and entry.widget
if not w then return end
local db = ArcUI_ProcTrackerDB and ArcUI_ProcTrackerDB.icons
and ArcUI_ProcTrackerDB.icons[entry.id]
if not db then return end
PT.SetFontSafe(w._deckText, db.deckFont, db.deckSize)
PT.SetFontSafe(w._procText, db.procFont, db.procSize)
PT.SetFontSafe(w._violText, db.violFont, db.violSize or 12)
PT.SetFontSafe(w._chanceText, db.chanceFont, db.chanceSize or 19)
end
local function IconDB(id)
local db = GetDB()
db.icons = db.icons or {}
db.icons[id] = db.icons[id] or {}
local t = db.icons[id]
-- Entry-specific defaults win over the shared ones, so a tracker can ship a
-- different starting strata, size or colour without every icon inheriting
-- it. Applied FIRST: both passes only fill nils, so whichever runs first
-- decides. A value the user has actually set is never touched by either.
local e = registryMap[id]
if e and e.iconDefaults then
for k, v in pairs(e.iconDefaults) do
if t[k] == nil then t[k] = v end
end
end
for k, v in pairs(ICON_DEFAULTS) do
if t[k] == nil then t[k] = v end
end
return t
end
-- Decks need their own saved settings (e.g. the DW "force CDM" override) before
-- the options panel has ever been opened, so expose the accessor.
PT.GetIconDB = IconDB
-- ── Border helpers (same method as CDMEnhance) ───────────────────────────────
local function GetClassColor()
local _, class = UnitClass("player")
local c = RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
if c then return c.r, c.g, c.b, 1 end
return 1, 1, 1, 1
end
local function CreateBorderEdges(frame)
if frame._arcPTBorderEdges then return frame._arcPTBorderEdges end
local edges = {}
for _, side in ipairs({"top","bottom","left","right"}) do
local t = frame:CreateTexture(nil, "OVERLAY", nil, -1)
t:SetColorTexture(1, 1, 1, 1)
t:SetSnapToPixelGrid(true)
t:SetTexelSnappingBias(1)
edges[side] = t
end
frame._arcPTBorderEdges = edges
return edges
end
local function UpdateBorder(frame, db, anchor)
local edges = frame._arcPTBorderEdges or CreateBorderEdges(frame)
anchor = anchor or frame -- anchor border to icon texture if provided
if not db.borderEnabled then
for _, t in pairs(edges) do t:Hide() end
return
end
local r, g, b, a
if db.borderUseClass then
r, g, b, a = GetClassColor()
else
r, g, b, a = db.borderR, db.borderG, db.borderB, db.borderA
end
local thickness = PixelUtil.GetNearestPixelSize(
db.borderThickness or 2, frame:GetEffectiveScale(), 1)
local insetX = PixelUtil.GetNearestPixelSize(
db.borderInset or 0, frame:GetEffectiveScale(), 0)
local insetY = insetX
edges.top:ClearAllPoints()
edges.top:SetPoint("TOPLEFT", anchor, "TOPLEFT", insetX, -insetY)
edges.top:SetPoint("TOPRIGHT", anchor, "TOPRIGHT", -insetX, -insetY)
edges.top:SetHeight(thickness); edges.top:SetVertexColor(r,g,b,a); edges.top:Show()
edges.bottom:ClearAllPoints()
edges.bottom:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", insetX, insetY)
edges.bottom:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -insetX, insetY)
edges.bottom:SetHeight(thickness); edges.bottom:SetVertexColor(r,g,b,a); edges.bottom:Show()
edges.left:ClearAllPoints()
edges.left:SetPoint("TOPLEFT", anchor, "TOPLEFT", insetX, -insetY)
edges.left:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", insetX, insetY)
edges.left:SetWidth(thickness); edges.left:SetVertexColor(r,g,b,a); edges.left:Show()
edges.right:ClearAllPoints()
edges.right:SetPoint("TOPRIGHT", anchor, "TOPRIGHT", -insetX, -insetY)
edges.right:SetPoint("BOTTOMRIGHT", anchor, "BOTTOMRIGHT", -insetX, insetY)
edges.right:SetWidth(thickness); edges.right:SetVertexColor(r,g,b,a); edges.right:Show()
end
-- ── AceConfig locals (declared early for widget helpers) ─────────────────────
local AceConfig = LibStub("AceConfig-3.0", true)
local AceConfigDialog = LibStub("AceConfigDialog-3.0", true)
local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true)
local PT_OPTIONS_NAME = "ArcUI_ProcTracker_Options"
-- Force the options panel to re-read function-valued names/descs.
--
-- Hits BOTH renderers on purpose. NotifyChange drives stock AceConfigDialog and,
-- via the ConfigTableChange callback in OpenSkinnedOptions, the ArcSkin window
-- too -- but that callback is only registered once the skinned window has been
-- opened through that path. Calling skin:Refresh directly as well means a live
-- status line updates regardless of which renderer is up or how it was opened.
--
-- Both calls are no-ops when the panel is closed.
function PT.RefreshOptions()
if AceConfigRegistry then AceConfigRegistry:NotifyChange(PT_OPTIONS_NAME) end
local skin = LibStub and LibStub("ArcSkin-1.0", true)
if skin and skin.Refresh then skin:Refresh(PT_OPTIONS_NAME) end
end
-- ── CDM frame lookup ──────────────────────────────────────────────────────────
-- Find an ACTIVE Cooldown Manager icon by its cooldownID, so a text overlay can
-- ride it and move wherever the player puts their CDM bars.
--
-- Walks itemFramePool:EnumerateActive(), NOT viewer:GetChildren(): CDM icons get
-- reparented (ArcUI moves them into its own groups), and a GetChildren sweep
-- silently misses those. Reading frame.cooldownID is a READ of a Blizzard field,
-- which never taints -- deliberately no CDM method calls or C_CooldownViewer
-- lookups here, since those are the paths that hand Blizzard code our taint.
local PT_CDM_VIEWERS = {
"BuffIconCooldownViewer",
"BuffBarCooldownViewer",
"EssentialCooldownViewer",
"UtilityCooldownViewer",
}
-- ── Action bar lookup ─────────────────────────────────────────────────────────
-- Find the action button currently holding one of `ids`, so a text overlay can
-- ride the player's own keybind button wherever their bars are.
--
-- Two sources, because no single one covers everything:
-- 1. Every LibActionButton-1.0 registry in LibStub. Bartender4 and ElvUI each
-- ship their OWN copy under a different major string, so enumerate rather
-- than hardcode a name -- this picks up future LAB bars for free.
-- 2. Known button names, for bars that are not LAB-based (Blizzard's own,
-- EllesmereUI).
--
-- `statehidden` buttons are skipped: a button on an inactive bar page still
-- exists and still reports an action slot, and anchoring to one would park the
-- text on something invisible.
--
-- A MACRO slot resolves to whatever it actually casts, so a "#showtooltip
-- Ascendance" macro matches exactly like a bare spell would.
local PT_BAR_PREFIXES = {
"ActionButton", "MultiBarBottomLeftButton", "MultiBarBottomRightButton",
"MultiBarRightButton", "MultiBarLeftButton",
"MultiBar5Button", "MultiBar6Button", "MultiBar7Button",
}
local function ButtonHoldsSpell(btn, ids)
if not btn then return false end
if btn.GetAttribute and btn:GetAttribute("statehidden") then return false end
if btn.IsShown and not btn:IsShown() then return false end
local slot = btn.action or (btn.GetAttribute and btn:GetAttribute("action"))
if type(slot) ~= "number" or not HasAction(slot) then return false end
local aType, id = GetActionInfo(slot)
if aType ~= "spell" and aType ~= "macro" then return false end
if not id then return false end
for i = 1, #ids do if ids[i] == id then return true end end
return false
end
function PT.FindActionButton(ids)
if type(ids) ~= "table" or #ids == 0 then return nil end
if LibStub and type(LibStub.libs) == "table" then
for major, lib in pairs(LibStub.libs) do
if type(major) == "string" and major:find("^LibActionButton%-1%.0")
and type(lib) == "table" and type(lib.GetAllButtons) == "function" then
for b in pairs(lib:GetAllButtons()) do
if ButtonHoldsSpell(b, ids) then return b end
end
end
end
end
for _, prefix in ipairs(PT_BAR_PREFIXES) do
for i = 1, 12 do
local b = _G[prefix .. i]
if ButtonHoldsSpell(b, ids) then return b end
end
end
return nil
end
function PT.FindCDMFrame(cooldownID)
if not cooldownID or cooldownID == 0 then return nil end
for _, viewerName in ipairs(PT_CDM_VIEWERS) do
local viewer = _G[viewerName]
if viewer and viewer.itemFramePool then
for frame in viewer.itemFramePool:EnumerateActive() do
if frame.cooldownID == cooldownID then return frame end
end
end
end
return nil
end
-- ── Deck proc chance ──────────────────────────────────────────────────────────
-- A deck is `size` cards holding `procs` procs, and every unit of resource
-- spent turns one card. So "will my next spender proc?" is hypergeometric:
-- draw K cards from the N remaining, which hold P remaining procs.
--
-- MissChance returns the probability of drawing NONE of the p procs. It is the
-- P-term form (at most a handful of factors) rather than the K-term one, which
-- keeps it exact in floating point and cheap:
-- C(n-p, k) / C(n, k) = product over j of (n-k-j) / (n-j)
local function MissChance(n, p, k)
if p <= 0 then return 1 end -- no procs left to hit
if k <= 0 then return 1 end -- drawing nothing hits nothing
if n - k < p then return 0 end -- drawing so many that a proc is certain
local miss = 1
for j = 0, p - 1 do
miss = miss * (n - k - j) / (n - j)
end
return miss
end
-- CONTINUITY is the part players actually ask about: a spend that runs off the
-- end of the deck keeps drawing into the NEXT deck, which is freshly stocked.
-- So sitting on 0 procs left near the end of a deck is NOT a flat zero.
-- Returns a PERCENTAGE (0-100).
function PT.DeckChance(size, maxProcs, pos, procsHit, spend)
size = size or 1
maxProcs = maxProcs or 0
local K = tonumber(spend) or 0
if K < 1 then K = 1 end
local N = size - (pos % size) -- cards left in this deck
local P = maxProcs - (procsHit or 0) -- procs left in this deck
if P < 0 then P = 0 end
if K < N then
return (1 - MissChance(N, P, K)) * 100
end
-- the spender consumes the rest of this deck; anything left over starts the
-- next one. Emptying a deck that still holds procs cannot miss.
local miss = (P > 0) and 0 or 1
local over = K - N
if over > 0 then
miss = miss * MissChance(size, maxProcs, over)
end
return (1 - miss) * 100
end
-- Shared formatting so every deck's readout looks the same. A decimal matters
-- at the low end: a full Doom Winds deck sits at 4.9%, and rounding that to 5%
-- throws away most of the range the number ever moves in.
function PT.FormatChance(pct, decimals)
if pct >= 99.95 then return "100%" end
if decimals ~= false and pct < 10 then return string.format("%.1f%%", pct) end
return string.format("%.0f%%", pct)
end
-- ── Widget helpers ────────────────────────────────────────────────────────────
-- Default colour for "N procs left", for any deck size: a full deck reads
-- green, a spent one red, anything between gold. Mirrors the proc counter's
-- own empty/half/full defaults so the two readouts start out agreeing.
-- NOTE the name: anything containing the literal "CountColor" trips the
-- pre-release upvalue checker, which matches defined names by SUBSTRING and
-- would flag this line as a call to CountColor below it.
local function ProcCountTint(left, maxProcs)
if left <= 0 then return 1, 0, 0 end
if left >= (maxProcs or 1) then return 0, 1, 0 end
return 1, 0.82, 0
end
-- The stored override for a count, if the user set one.
local function CountColor(db, left, maxProcs)
local t = db.chanceCountColors and db.chanceCountColors[left]
if t then return t[1] or 1, t[2] or 1, t[3] or 1 end
return ProcCountTint(left, maxProcs)
end
PT.CountColor = CountColor
-- Where a given text hangs. 0 / nil = this widget's own icon (the default and
-- the old behaviour). Anything else is a CDM cooldownID: the text rides that
-- icon and moves with the player's CDM bars.
--
-- FALLS BACK to the widget's icon whenever the CDM frame is not up (talent not
-- taken, spell not on their bars, mid-reshuffle). A text that silently vanishes
-- because its anchor went away is far worse than one that goes home.
-- The anchor SPEC is a string so it can name what kind of thing it points at:
-- nil / "" / "0" this widget's own icon (the default, and the old behaviour)
-- "cdm:<cooldownID>" a Cooldown Manager icon
-- "action:<a,b,c>" an action button holding ANY of those spell IDs. A list,
-- because a spell can sit on the bar under more than one id
-- (Ascendance is 114051 and 384352), and which one the bar
-- reports depends on overrides.
-- A bare number is still accepted: this shipped as a cooldownID for one session
-- before action bars were an option.
local function ParseIDs(csv)
local t = {}
for n in tostring(csv):gmatch("[-]?%d+") do t[#t + 1] = tonumber(n) end
return t
end
-- spec -> last frame we resolved it to. Never stale-dangerous: every read is
-- validated before use, so a wrong entry costs one failed check and a re-sweep.
local anchorCache = {}
local anchorIDs = {} -- spec -> parsed id list, so the pattern match is done once
function PT.InvalidateAnchorCache()
wipe(anchorCache)
end
local function ResolveAnchor(spec)
local ids = anchorIDs[spec]
if not ids then
local kind, rest = spec:match("^(%a+):(.+)$")
if not kind then return nil end
ids = { kind = kind, list = ParseIDs(rest) }
anchorIDs[spec] = ids
end
-- validate the remembered frame first: O(1), and the common case
local cached = anchorCache[spec]
if cached then
if ids.kind == "cdm" then
if cached.cooldownID == ids.list[1] then return cached end
elseif ids.kind == "action" then
if ButtonHoldsSpell(cached, ids.list) then return cached end
end
anchorCache[spec] = nil
end
local found
if ids.kind == "cdm" then
found = PT.FindCDMFrame(ids.list[1])
elseif ids.kind == "action" then
found = PT.FindActionButton(ids.list)
end
anchorCache[spec] = found
return found
end
-- ── Timer state visuals ───────────────────────────────────────────────────────
-- ONE writer for the icon's look, so alpha/desaturate/tint/glow can never fight
-- each other. Called from UpdateIcon's timer branch with the current state; it
-- reads that state's settings and applies all four.
-- Resolved lazily, NOT at file scope: Core loads before some libraries, and a
-- file-scope grab would cache nil forever and silently kill every glow.
local GLOW_KEY = "ArcPTState"
local function GetLCG()
return LibStub and LibStub("LibCustomGlow-1.0", true)
end
local function StopStateGlow(w)
local LCG = GetLCG()
if not (LCG and w._icon) then return end
local host = w._glowHost
if not host then return end
LCG.PixelGlow_Stop(host, GLOW_KEY)
LCG.AutoCastGlow_Stop(host, GLOW_KEY)
LCG.ButtonGlow_Stop(host, GLOW_KEY)
LCG.ProcGlow_Stop(host, GLOW_KEY)
end
-- The sweep the game draws over the icon. Every knob here is a plain widget
-- setter, so none of it touches the duration itself -- the timer stays exactly
-- as secret-safe as it was.
local function ApplyCooldownLook(w, db)
local cd = w._ngCooldown
if not cd then return end
cd:SetDrawSwipe(db.swipeShow ~= false)
cd:SetSwipeColor(db.swipeR or 0, db.swipeG or 0, db.swipeB or 0,
(type(db.swipeA) == "number") and db.swipeA or 0.8)
cd:SetDrawEdge(db.swipeEdge == true)
cd:SetHideCountdownNumbers(db.swipeNumbers == false)
cd:SetDrawBling(db.swipeBling ~= false)
cd:SetReverse(db.swipeReverse == true)
end
local function ApplyStateVisuals(w, db, onCD)
if not w._icon then return end
local p = onCD and "cd" or "rdy"
local alpha = db[p .. "Alpha"]
if type(alpha) ~= "number" then alpha = 1 end
w._icon:SetAlpha(alpha)
w._icon:SetDesaturated(db[p .. "Desat"] == true)
if db[p .. "Tint"] == true then
w._icon:SetVertexColor(db[p .. "TintR"] or 1, db[p .. "TintG"] or 1, db[p .. "TintB"] or 1)
else
w._icon:SetVertexColor(1, 1, 1)
end
-- Glow needs a FRAME to hang on; the icon is a texture. One host per widget.
--
-- PADDING AND MOVEMENT BOTH LIVE ON THE HOST, and LCG's own xOffset/yOffset
-- are left at zero. Those arguments mean EXPANSION from the frame edge, not
-- movement -- feeding a "move the glow down" value into them just grows the
-- halo, which is the documented "proc glow sits lower" bug. Sizing the host
-- for padding and anchoring it for movement keeps one mechanism for each and
-- makes both behave identically across every style.
local LCG = GetLCG()
if not LCG then return end
if not w._glowHost then
w._glowHost = CreateFrame("Frame", nil, w)
end
StopStateGlow(w)
if db[p .. "Glow"] ~= true then return end
local iw, ih = w._icon:GetSize()
if not iw or iw < 1 then iw, ih = 32, 32 end
local pad = tonumber(db[p .. "GlowPad"]) or 0
w._glowHost:ClearAllPoints()
w._glowHost:SetSize(iw + pad * 2, ih + pad * 2)
w._glowHost:SetPoint("CENTER", w._icon, "CENTER",
tonumber(db[p .. "GlowX"]) or 0, tonumber(db[p .. "GlowY"]) or 0)
local col = { db[p .. "GlowR"] or 0.95, db[p .. "GlowG"] or 0.95, db[p .. "GlowB"] or 0.32, 1 }
local style = db[p .. "GlowStyle"] or "pixel"
local speed = tonumber(db[p .. "GlowSpeed"]) or 0.25
if style == "autocast" then
LCG.AutoCastGlow_Start(w._glowHost, col,
tonumber(db[p .. "GlowParticles"]) or 4, speed,
tonumber(db[p .. "GlowScale"]) or 1, 0, 0, GLOW_KEY)
elseif style == "button" then
LCG.ButtonGlow_Start(w._glowHost, col, speed, nil, GLOW_KEY, 0, 0)
elseif style == "proc" or style == "procloop" then
-- same art; the loop variant skips the burst intro, which on a state
-- that is already running reads as a proc that just happened
LCG.ProcGlow_Start(w._glowHost, {
color = col, key = GLOW_KEY,
startAnim = (style == "proc"),
duration = tonumber(db[p .. "GlowDuration"]) or 1,
})
else
-- length 0 means "let the library work it out from the frame size"
local len = tonumber(db[p .. "GlowLength"]) or 0
LCG.PixelGlow_Start(w._glowHost, col,
tonumber(db[p .. "GlowLines"]) or 8, speed,
(len > 0) and len or nil,
tonumber(db[p .. "GlowThick"]) or 2,
0, 0, db[p .. "GlowBorder"] == true, GLOW_KEY)
end
end
local function TextAnchor(w, spec)
-- Fast path, and the default: no anchor set means no lookup at all, so a
-- user who never turns this on pays a single comparison.
if spec == nil or spec == 0 or spec == "0" or spec == "" then return w._icon end
if type(spec) == "number" then spec = "cdm:" .. spec end
if type(spec) ~= "string" then return w._icon end
return ResolveAnchor(spec) or w._icon
end
PT.TextAnchor = TextAnchor
local function ProcColor(db, procs, maxProcs)
if db.procCountDown then
local rem = maxProcs - procs
if rem == maxProcs then return db.emptyR, db.emptyG, db.emptyB
elseif rem > 0 then return db.halfR, db.halfG, db.halfB
else return db.fullR, db.fullG, db.fullB end
else
if procs == 0 then return db.emptyR, db.emptyG, db.emptyB
elseif procs < maxProcs then return db.halfR, db.halfG, db.halfB
else return db.fullR, db.fullG, db.fullB end
end
end
local GetDeckNS -- forward declared, defined before BuildDeckOptionsGroup
local function UpdateIcon(entry)
local w = entry.widget
if not w then return end
local db = IconDB(entry.id)
local textOnly = db.textOnly == true
-- CDM tracking warning overlay (suppressed in text-only mode)
if w._cdmWarn then
if textOnly then
w._cdmWarn:Hide()
if w._cdmWarnText then w._cdmWarnText:Hide() end
else
local ns = GetDeckNS(entry.id)
if entry.noCDMWarn then
w._cdmWarn:Hide()
if w._cdmWarnText then w._cdmWarnText:Hide() end
else
local cdmOk = ns and ns.IsCDMTracking and ns.IsCDMTracking()
if cdmOk then
w._cdmWarn:Hide()
if w._cdmWarnText then w._cdmWarnText:Hide() end
else
w._cdmWarn:Show()
if w._cdmWarnText then w._cdmWarnText:Show() end
end
end
end
end
-- Violation text
if w._violText then
if db.showViolations and entry.GetViolations then
local v = entry.GetViolations()
local r = db.violR or 1
local g = db.violG or 0.2
local b = db.violB or 0.2
w._violText:SetTextColor(r, g, b)
PT.SetFontSafe(w._violText, db.violFont, db.violSize or 12)
w._violText:SetText(tostring(v))
w._violText:ClearAllPoints()
w._violText:SetPoint("CENTER", TextAnchor(w, db.violAnchor), "CENTER", db.violOffX or 0, db.violOffY or -20)
w._violText:Show()
else
w._violText:Hide()
end
end
-- A timer entry has no deck, so none of the deck-shaped readouts mean
-- anything for it. Bail after the icon itself is drawn.
if entry.isTimer then
local onCD = entry.ns and entry.ns.IsOnCooldown and entry.ns.IsOnCooldown() or false
if w._previewState then onCD = (w._previewState == "cd") end
if w._icon then
w._icon:SetShown(not textOnly)
ApplyStateVisuals(w, db, onCD)
end
ApplyCooldownLook(w, db)
if w._ngCooldown then w._ngCooldown:SetShown(not textOnly) end
if w._deckText then w._deckText:Hide() end
if w._procText then w._procText:Hide() end
if w._chanceText then w._chanceText:Hide() end
if w._violText then w._violText:Hide() end
if w._cdmWarn then w._cdmWarn:Hide() end
if w._cdmWarnText then w._cdmWarnText:Hide() end
if textOnly then
if w._arcPTBorderEdges then
for _, t in pairs(w._arcPTBorderEdges) do t:Hide() end
end
else
UpdateBorder(w, db, w._icon)
end
return
end
local deckSize = entry.deckSize
local maxProcs = entry.procs
local raw = entry.GetDeckPos() -- 0-based position in deck
local procs = entry.GetProcs()
local pos = db.countDown and (deckSize - raw) or raw
local r, g, b = ProcColor(db, procs, maxProcs)
local suffix = db.showDeckSuffix and ("/" .. deckSize) or ""
local procDisp = db.procCountDown and (maxProcs - procs) or procs
local procSuffix = db.showProcSuffix and ("/" .. maxProcs) or ""
-- Text-only mode: hide icon texture; text anchors remain valid
-- (FontStrings stay anchored to the invisible icon region).
if textOnly then
if w._icon then w._icon:Hide() end
else
if w._icon then
w._icon:Show()
-- Desaturate when all procs for this deck are used up
w._icon:SetDesaturated(db.desaturateEmpty == true and procs >= maxProcs)
end
end
-- Optional per-deck text override. A tracker whose meaningful readout is not
-- a deck position (e.g. Soulburst, an escalating-chance proc where the useful
-- number is the CHANCE, not a card index) supplies GetDeckText/GetProcText and
-- formats its own string. Decks that do not define them are untouched.
local deckStr = tostring(pos) .. suffix
if entry.GetDeckText then
local s = entry.GetDeckText()
if s ~= nil then deckStr = s end
end
PT.SetFontSafe(w._deckText, db.deckFont, db.deckSize)
w._deckText:SetShadowOffset(1, -1); w._deckText:SetShadowColor(0, 0, 0, 1)
w._deckText:SetText(deckStr)
w._deckText:SetTextColor(db.deckR, db.deckG, db.deckB)
w._deckText:ClearAllPoints()
w._deckText:SetPoint("CENTER", TextAnchor(w, db.deckAnchor), "CENTER", db.deckOffX, db.deckOffY)
w._deckText:SetShown(db.showDeckText ~= false)
-- ── PROC CHANCE readout ────────────────────────────────────────────────
-- Its own text, not a mode on the deck text, so it can sit alongside the
-- card index rather than replacing it. Only decks that can actually work
-- out a chance (GetChanceText) ever draw it.
if w._chanceText then
local wantChance = (db.showChanceText == true) and entry.GetChanceText ~= nil
if wantChance then
local pct = entry.GetChanceValue and entry.GetChanceValue() or nil
local str = entry.GetChanceText()
w._chanceText:SetText(str or "")
local cr, cg, cb = db.chanceR or 1, db.chanceG or 1, db.chanceB or 1
local mode = db.chanceColorMode or "fixed"
if mode == "procs" then
-- keyed on procs LEFT (what actually drives the chance), not on
-- procs hit, so the colour tracks the number the reading means
local left = maxProcs - procs
if left < 0 then left = 0 end
cr, cg, cb = CountColor(db, left, maxProcs)
elseif mode == "chance" and pct then
local lo = db.chanceLowPct or 3
local hi = db.chanceHighPct or 10
if pct >= hi then
cr, cg, cb = db.chanceHotR or 0, db.chanceHotG or 1, db.chanceHotB or 0
elseif pct < lo then
cr, cg, cb = db.chanceColdR or 0.6, db.chanceColdG or 0.6, db.chanceColdB or 0.6
else
cr, cg, cb = db.chanceMidR or 1, db.chanceMidG or 0.82, db.chanceMidB or 0
end
end
PT.SetFontSafe(w._chanceText, db.chanceFont, db.chanceSize or 19)
w._chanceText:SetShadowOffset(1, -1); w._chanceText:SetShadowColor(0, 0, 0, 1)
w._chanceText:SetTextColor(cr, cg, cb)
w._chanceText:ClearAllPoints()
w._chanceText:SetPoint("CENTER", TextAnchor(w, db.chanceAnchor), "CENTER",
db.chanceOffX or 0, db.chanceOffY or -27)
end
w._chanceText:SetShown(wantChance)
end
w._procText:SetShown(db.showProcText ~= false)
local procStr = tostring(procDisp) .. procSuffix
if entry.GetProcText then
local s = entry.GetProcText()
if s ~= nil then procStr = s end
end
PT.SetFontSafe(w._procText, db.procFont, db.procSize)
w._procText:SetShadowOffset(1, -1); w._procText:SetShadowColor(0, 0, 0, 1)
w._procText:SetText(procStr)
w._procText:SetTextColor(r, g, b)
w._procText:ClearAllPoints()
w._procText:SetPoint("CENTER", TextAnchor(w, db.procAnchor), "CENTER", db.procOffX, db.procOffY)
-- Border — hidden in text-only mode, otherwise applied to icon texture
if textOnly then
if w._arcPTBorderEdges then
for _, t in pairs(w._arcPTBorderEdges) do t:Hide() end
end
else
UpdateBorder(w, db, w._icon)
end
-- Resync text drag handles to follow the current text bounds
if w._deckTextHandle and w._deckTextHandle._resync then w._deckTextHandle._resync() end
if w._procTextHandle and w._procTextHandle._resync then w._procTextHandle._resync() end
if w._violTextHandle and w._violTextHandle._resync then w._violTextHandle._resync() end
end
-- PREVIEW: hold the icon in one state for a few seconds so its look can be
-- judged without waiting for the real thing. UpdateIcon reads _previewState
-- INSTEAD of the live state while it is set, so the preview cannot be stomped
-- by a refresh landing mid-preview (a proc, a bar event, an options rerender).
local PREVIEW_SECONDS = 6
function PT.PreviewState(entry, p)
local w = entry and entry.widget
if not w then return end
w._previewState = p
-- Show the sweep too when previewing the cooldown look, but never while a
-- REAL cooldown is running -- overwriting it would lie about the timer.
local live = entry.ns and entry.ns.IsOnCooldown and entry.ns.IsOnCooldown()
if p == "cd" and w._ngCooldown and not live then
w._ngCooldown:SetCooldown(GetTime(), PREVIEW_SECONDS)
end
UpdateIcon(entry)
C_Timer.After(PREVIEW_SECONDS, function()
if w._previewState ~= p then return end -- a newer preview took over
w._previewState = nil
-- hand the icon back to whoever actually owns its state
if entry.ns and entry.ns.Refresh then entry.ns.Refresh() else UpdateIcon(entry) end
end)
end
local function ApplyIconSize(f, w, h)
f:SetSize(w + 4, h + 14)
if f._icon then f._icon:SetSize(w, h) end
end
-- ── Text drag handles ────────────────────────────────────────────────────────
-- Creates an invisible mouse-enabled frame on top of a FontString.
-- When "unlock texts" is on, dragging the handle updates the offset DB keys
-- (offXKey/offYKey relative to the icon's CENTER) and refreshes the icon.
-- onRefresh() is called after drag stop so the options panel updates.
local function MakeTextDragHandle(parent, fontString, anchorTo, getDB, offXKey, offYKey, onRefresh)
-- Manual drag with OnMouseDown/OnMouseUp (NOT RegisterForDrag) so motion
-- starts the instant the button is pressed — no WoW drag threshold.
local h = CreateFrame("Frame", nil, parent)
h:SetFrameStrata(parent:GetFrameStrata())
h:SetFrameLevel((parent:GetFrameLevel() or 5) + 10)
h:EnableMouse(false)
local outline = h:CreateTexture(nil, "OVERLAY")
outline:SetAllPoints(h)
outline:SetColorTexture(0.2, 0.8, 1.0, 0.18)
outline:Hide()
h._outline = outline
-- anchorTo may be a FRAME or a FUNCTION returning one. It has to be able to
-- change: a text can ride a CDM icon, and CDM swaps those frames out from
-- under us on every layout refresh.
local function AT()
if type(anchorTo) == "function" then return anchorTo() end
return anchorTo
end
-- Anchor handle directly to the anchor (the icon) at the current text offset.
-- The FontString's offset and the handle's offset use the same value,
-- so dragging the handle directly mutates that offset in DB coordinates.
local function Resync()
local tw, th = fontString:GetStringWidth(), fontString:GetStringHeight()
if tw < 12 then tw = 12 end
if th < 12 then th = 12 end
h:SetSize(tw + 6, th + 4)
local d = getDB()
h:ClearAllPoints()
h:SetPoint("CENTER", AT(), "CENTER", d[offXKey] or 0, d[offYKey] or 0)
end
h._resync = Resync
-- Drag state
local dragging
local dragStartCX, dragStartCY
local dragStartOffX, dragStartOffY
local function StopDrag(self)
if not dragging then return end
dragging = false
self:SetScript("OnUpdate", nil)
local x, y = GetCursorPosition()
local sc = parent:GetEffectiveScale()
local dx = x / sc - dragStartCX
local dy = y / sc - dragStartCY
local newX = math.floor(dragStartOffX + dx + 0.5)
local newY = math.floor(dragStartOffY + dy + 0.5)
local d = getDB()
d[offXKey] = newX
d[offYKey] = newY
self:ClearAllPoints()
self:SetPoint("CENTER", AT(), "CENTER", newX, newY)
dragStartCX, dragStartCY = nil, nil
if onRefresh then onRefresh() end
if AceConfigRegistry then
AceConfigRegistry:NotifyChange(PT_OPTIONS_NAME)
end
end
h:SetScript("OnMouseDown", function(self, button)
if button ~= "LeftButton" then return end
if not self:IsMouseEnabled() then return end
local cx, cy = GetCursorPosition()
local scale = parent:GetEffectiveScale()
dragStartCX = cx / scale
dragStartCY = cy / scale
local d = getDB()
dragStartOffX = d[offXKey] or 0
dragStartOffY = d[offYKey] or 0
dragging = true
self:SetScript("OnUpdate", function(s)
local x, y = GetCursorPosition()
local sc = parent:GetEffectiveScale()
local dx = x / sc - dragStartCX
local dy = y / sc - dragStartCY
local newX = dragStartOffX + dx
local newY = dragStartOffY + dy
s:ClearAllPoints()
s:SetPoint("CENTER", AT(), "CENTER", newX, newY)
fontString:ClearAllPoints()
fontString:SetPoint("CENTER", AT(), "CENTER", newX, newY)
end)
end)
h:SetScript("OnMouseUp", function(self, button)
if button ~= "LeftButton" then return end
StopDrag(self)
end)
-- Safety: if the cursor leaves the handle while the button is still held,
-- WoW won't fire OnMouseUp on the handle. Watch for button release globally.
h:SetScript("OnHide", function(self) StopDrag(self) end)
return h
end
local function ApplyTextDragHandleState(entry, unlocked)
local w = entry.widget
if not w then return end
for _, key in ipairs({"_deckTextHandle", "_procTextHandle", "_violTextHandle", "_chanceTextHandle"}) do
local h = w[key]
if h then
h:EnableMouse(unlocked == true)
if h._outline then h._outline:SetShown(unlocked == true) end
if unlocked and h._resync then h._resync() end
end
end
end
-- ── Widget helpers (continued) ───────────────────────────────────────────────
local function BuildIconWidget(entry)
local db = IconDB(entry.id)
local id = entry.id
local w = db.iconW or 48
local h = db.iconH or 48
local f = CreateFrame("Frame", "ArcUI_PT_Icon_" .. id, UIParent)
f:SetSize(w + 4, h + 14)
f:SetScale(db.iconScale or 1.0)
f:SetFrameStrata(db.frameStrata or "HIGH")
f:SetFrameLevel(db.frameLevel or 5)
-- Load anchor: use saved anchor type if present, fall back to CENTER/CENTER.
-- StopMovingOrSizing may have reanchored to a screen corner, so we store
-- the full anchor info (point + relativePoint) not just offsets.
f:SetPoint(
db.posPoint or "CENTER",
UIParent,
db.posRelPoint or "CENTER",
db.posX or 0,
db.posY or 0
)
f:SetClampedToScreen(true)
local locked = db.lockPosition == true
f:SetMovable(not locked)
f:EnableMouse(not locked)
f:RegisterForDrag("LeftButton") -- always register; OnDragStart guard handles lock