-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathRegionPrototype.lua
More file actions
1621 lines (1526 loc) · 67.4 KB
/
Copy pathRegionPrototype.lua
File metadata and controls
1621 lines (1526 loc) · 67.4 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
-- WeakestAuras -- the shared region base every region type mixes in at create
-- time. Mirrors WA2's RegionPrototype (§7).
-- Upstream section refs (§N) point at design/architecture/weakauras2-reference.md
if WeakestAuras.disabled then return end
local WA = WeakestAuras
WA.regionPrototype = {}
local proto = WA.regionPrototype
-- Parses an adjusted-min/max field: absolute ("12") or relative ("20%").
-- Returns exactly one of (absolute, relPercent) non-nil, or both nil for an
-- empty or unparseable value -- user input must never error here.
local function ParseAdjust(v)
if not v or v == "" then return nil, nil end
local index = string.find(v, "%% *$")
if index then
local percent = tonumber(string.sub(v, 1, index - 1))
if not percent then return nil, nil end
return nil, percent / 100
end
return tonumber(v), nil
end
-- A tiny subscribable object -- the per-region event bus subregions subscribe
-- to (§8). Port of the idea, not upstream's file. Notifies with up to three
-- payload args, which covers "Update"(state, states) and the parameterless
-- lifecycle events (PreShow/PreHide/FrameTick).
local function CreateSubscribers()
local obj = { subs = {} }
function obj:AddSubscriber(event, fn)
self.subs[event] = self.subs[event] or {}
table.insert(self.subs[event], fn)
end
function obj:Notify(event, a, b, c)
local list = self.subs[event]
if not list then return end
for i = 1, table.getn(list) do list[i](a, b, c) end
end
-- Takes the same function object AddSubscriber was given. A region whose text
-- a condition can replace has to be able to leave the FrameTick bus again, not
-- only join it.
function obj:RemoveSubscriber(event, fn)
local list = self.subs[event]
if not list or not fn then return end
for i = table.getn(list), 1, -1 do
if list[i] == fn then table.remove(list, i) end
end
end
-- Dropped and rebuilt on every modifyFinish so a re-config's stale closures
-- (pointing at replaced sub-region instances) never keep firing.
function obj:Clear() self.subs = {} end
return obj
end
-- One shared OnUpdate driving the FrameTick bus (§7 FrameTick): only regions
-- with a %p text subscribe, and only while shown, so a display with no per-frame
-- text costs nothing. RegionPrototype owns the set; Expand/Collapse and
-- modifyFinish move regions in/out of it.
local frameTickRegions = {}
local tickFrame = CreateFrame("Frame")
tickFrame:SetScript("OnUpdate", function()
for region in pairs(frameTickRegions) do
region.subRegionEvents:Notify("FrameTick")
end
end)
function proto.RegisterForFrameTick(region) frameTickRegions[region] = true end
function proto.UnregisterForFrameTick(region) frameTickRegions[region] = nil end
function proto.CountFrameTick()
local n = 0
for _ in pairs(frameTickRegions) do n = n + 1 end
return n
end
-- Mixed into a region frame by each region type's create(), after its own
-- frames are built.
function proto.create(region)
region.xOffset, region.yOffset = 0, 0
region.xOffsetAnim, region.yOffsetAnim = 0, 0
region.xOffsetRelative, region.yOffsetRelative = 0, 0
region.selfPoint = "CENTER"
region.anchorFrame = UIParent
region.anchorPoint = "CENTER"
region.regionAlpha = 1
region.animAlpha = nil
region.animatingFinish = false
region.pendingRelease = false
region.toShow = false
region.limited = false
region.shown = false
region.state = nil
region.states = {}
region.subRegionEvents = CreateSubscribers()
if WA.AttachActionMethods then WA.AttachActionMethods(region) end
-- Effective position composes config + animation + relative(condition)
-- offsets, so those three never fight over SetPoint (§7). Anim/relative
-- slots stay zero until animations/conditions exist, but the composition is
-- built now to prevent that bug class later.
function region:UpdatePosition()
local x = (self.xOffset or 0) + (self.xOffsetAnim or 0) + (self.xOffsetRelative or 0)
local y = (self.yOffset or 0) + (self.yOffsetAnim or 0) + (self.yOffsetRelative or 0)
self:ClearAllPoints()
self:SetPoint(self.selfPoint, self.anchorFrame or UIParent, self.anchorPoint, x, y)
end
function region:SetAnchor(selfPoint, anchorFrame, anchorPoint)
self.selfPoint = selfPoint or "CENTER"
self.anchorFrame = anchorFrame or UIParent
self.anchorPoint = anchorPoint or "CENTER"
self:UpdatePosition()
end
function region:SetOffset(x, y) self.xOffset, self.yOffset = x, y; self:UpdatePosition() end
function region:SetOffsetAnim(x, y) self.xOffsetAnim, self.yOffsetAnim = x, y; self:UpdatePosition() end
function region:SetXOffsetRelative(x) self.xOffsetRelative = x; self:UpdatePosition() end
function region:SetYOffsetRelative(y) self.yOffsetRelative = y; self:UpdatePosition() end
function region:SetRegionAlpha(a)
a = a or 1
self.regionAlpha = a
self:SetAlpha(a * (self.animAlpha or 1))
end
function region:GetRegionAlpha() return self.regionAlpha or 1 end
function region:SetAnimAlpha(a)
self.animAlpha = a
self:SetAlpha((self.regionAlpha or 1) * (a or 1))
end
-- Deliberately named the same as data.adjustedMin/Max (a string on the data
-- table) -- upstream's convention.
function region:SetAdjustedMin(v)
self.adjustedMin, self.adjustedMinRelPercent = ParseAdjust(v)
proto.UpdateProgress(self)
end
function region:SetAdjustedMax(v)
self.adjustedMax, self.adjustedMaxRelPercent = ParseAdjust(v)
proto.UpdateProgress(self)
end
-- -1 automatic (region.state), 0 manual (region.progressSourceManualValue/
-- Total), N > 0 trigger N's state (region.states[N]).
function region:SetProgressSource(v)
self.progressSource = v
proto.UpdateProgress(self)
end
-- The value range applyStatic/applyTimed last clamped to (post adjusted-
-- min/max), not the raw state -- a threshold sub-region reads this rather
-- than re-deriving it from data. 0/0 before the first UpdateProgress.
function region:GetMinMaxProgress()
return self.minProgress or 0, self.maxProgress or 0
end
-- toShow guards keep repeated Expand/Collapse idempotent (the state machine
-- may re-apply a shown state many times). Actions/animations hook these
-- later without another refactor (§7).
--
-- toShow (state machine: has a state to render) and limited (owning dynamic
-- group: past its visible-clone cap) are independent flags; region.shown --
-- the actual Show/Hide -- is toShow AND NOT limited. setShown is the only
-- place that compares against it, so a limit flip and an Expand/Collapse both
-- go through the same idempotent gate instead of each guarding separately.
local function setShown(self, want)
if self.shown == want then return end
self.shown = want
if want then
self.subRegionEvents:Notify("PreShow")
self:Show()
if self._hasFrameTick then proto.RegisterForFrameTick(self) end
else
self.subRegionEvents:Notify("PreHide")
proto.UnregisterForFrameTick(self)
self:Hide()
end
end
function region:Expand()
if self.toShow then return end
if self.animatingFinish then
WA.CancelAnimation(self, true, true, true, true, true, false)
self.animatingFinish = false
end
self.toShow = true
setShown(self, not self.limited)
WA.PerformActions(WeakestAurasDB.displays[self.id], "start", self)
local data = WeakestAurasDB.displays[self.id]
local function startMainAnimation()
if not data then return end
WA.Animate("display", data.uid, "main", data.animation and data.animation.main,
self, false, nil, true, self.cloneId)
end
if not data or not WA.Animate("display", data.uid, "start", data.animation and data.animation.start,
self, true, startMainAnimation, false, self.cloneId) then
startMainAnimation()
end
end
function region:Collapse(onFinished)
if not self.toShow then return end
self.toShow = false
self.limited = false
if self.SoundRepeatStop then self:SoundRepeatStop() end
if self.StopExternalGlows then self:StopExternalGlows() end
WA.PerformActions(WeakestAurasDB.displays[self.id], "finish", self)
local data = WeakestAurasDB.displays[self.id]
local function hideRegion()
self.animatingFinish = false
WA.CancelAnimation(self, true, true, true, true, true, false)
setShown(self, false)
if onFinished then onFinished() end
end
self.animatingFinish = true
if not data or not WA.Animate("display", data.uid, "finish", data.animation and data.animation.finish,
self, false, hideRegion, false, self.cloneId) then
hideRegion()
end
end
-- The dynamic group's visible-clone cap. Toggling this alone (toShow already
-- true) can flip the actual Show/Hide without going through Expand/Collapse,
-- which is what lets a clone stay fully alive -- state, conditions, pooling
-- identity -- while only its paint is suppressed.
function region:SetLimited(limited)
limited = limited and true or false
if self.limited == limited then return end
self.limited = limited
if self.toShow then setShown(self, not limited) end
end
-- The two custom-text update modes are different *call counts*, not
-- "throttled or not" (WA2 Text.lua). `event` runs the function once per state
-- update and ignores the throttle entirely -- that is what `force` means
-- here. `update` runs it on FrameTick, and there the throttle is the
-- difference between a feature and a frame-rate bug.
--
-- Every region type's own Update must call this with force before it (or its
-- sub-regions) resolve a placeholder: StateMachine runs region:Update ahead
-- of the sub-region bus, so a refresh at the top of Update is what makes the
-- values every subtext then reads cost exactly one run.
function region:RefreshCustomText(force)
if not self.customTextFunc then return end
if not force then
local last = self.lastCustomTextUpdate
if last and last + (self.customTextThrottle or 0) >= GetTime() then return end
end
self.customValues = WA.RunCustomTextFunc(self, self.customTextFunc)
self.lastCustomTextUpdate = GetTime()
end
-- One stable function object, so modifyFinish's rebuild can put the same one
-- back rather than accumulating closures.
region.customTextTick = function() region:RefreshCustomText() end
end
-- ---------------------------------------------------------------------------
-- %c custom text (§9)
-- ---------------------------------------------------------------------------
-- The function belongs to the *aura*, not to whatever renders it: upstream
-- compiles data.customText once and every text of the display indexes the same
-- result array, so five subtexts sharing %c1..%c5 cost one run per state update
-- rather than five. It lives on the region for that reason -- an icon has no
-- text of its own, every icon text being a sub-region, so machinery kept on the
-- text region left %c unavailable everywhere it is most wanted.
-- The default function, seeded into a fresh field and restored by the editor's
-- Reset -- an emptied box is otherwise unrecoverable without remembering the
-- signature. Arguments are WA.RunCustomTextFunc's, and every return is a %c:
-- the first is %c or %c1, the second %c2, and so on.
local CUSTOM_TEXT_DEFAULT = [[function(expirationTime, duration, progress, dur, name, icon, stacks)
return ""
end]]
-- Upstream defaults the throttle to 0, which in `update` mode is the custom
-- function running on every frame. A default that costs frame rate is not a
-- default. Resolved here rather than seeded into each region type's `defaults`,
-- so the number is written once -- `or` catches only nil, so a user who
-- deliberately chooses 0 still gets 0.
local CUSTOM_TEXT_THROTTLE = 0.2
-- Every string this aura might render: the region's own displayText, each
-- subtext's text_text, and anything a condition swaps into either. The compile
-- and the options gate both ask this, so the two cannot disagree -- an aura
-- whose only %c arrives through a condition would otherwise compile a function
-- it offers no editor to write (WA2's hideCustomTextOption walks conditions for
-- the same reason).
function proto.TextStrings(data)
local texts = {}
if data.displayText then table.insert(texts, data.displayText) end
local subs = data.subRegions or {}
for i = 1, table.getn(subs) do
local sub = subs[i]
if sub.type == "subtext" and sub.text_text then table.insert(texts, sub.text_text) end
end
local conditions = data.conditions or {}
for i = 1, table.getn(conditions) do
local changes = conditions[i].changes or {}
for c = 1, table.getn(changes) do
local change = changes[c]
-- "displayText" on the region itself, "sub.<n>.text_text" on a subtext.
if type(change.value) == "string" and change.property
and (change.property == "displayText"
or string.find(change.property, "%.text_text$")) then
table.insert(texts, change.value)
end
end
end
return texts
end
function proto.WantsCustomText(data)
return WA.ContainsCustomPlaceHolder(proto.TextStrings(data))
end
-- Compiles data.customText onto the region, memoized on the source it came
-- from. modify is not the cold path it looks like: a `range` field's `set` calls
-- WA.Add, NewSlider's onChange fires on every step of a drag, and modify runs
-- each time -- so dragging a slider on an aura using %c would otherwise be one
-- loadstring per frame. The key has to be the source rather than a dirty flag: a
-- `set` writing the same text back (which a drag does to every *other* field)
-- must compare equal. Memoizing the whole attempt, failure included, is also
-- what keeps a broken function from reporting once per frame of that drag.
local function applyCustomText(region, data)
region.customValues = nil
region.lastCustomTextUpdate = nil
region.customTextMode = data.customTextUpdate or "event"
region.customTextThrottle = data.customTextUpdateThrottle or CUSTOM_TEXT_THROTTLE
local source = data.customText
if source == "" then source = nil end
if not source or not proto.WantsCustomText(data) then
region.customTextFunc, region.customTextSource = nil, nil
elseif region.customTextSource ~= source then
region.customTextSource = source
region.customTextFunc = WA.LoadFunction(source, tostring(data.id) .. ": custom text")
-- The source changed, so whatever the old code left in aura_env belongs to
-- code that no longer exists.
WA.ClearAuraEnv(data.id)
end
end
-- The Custom Text block -- the code editor, the update mode, and the throttle
-- that only decides anything in per-frame mode. One block per *aura* rather than
-- one per text, since that is what the function is: rendered once on the Display
-- tab (OptionsFrame's appendDisplayEffectsOptions) rather than inside each
-- subtext's own section, which would put N identical editors on one page --
-- upstream shows one subtext at a time and can afford to repeat them.
-- Empty until something in the aura's text actually references %c, which is also
-- how the connection between %c and this block is taught.
function proto.CustomTextOptionFields(data)
if not proto.WantsCustomText(data) then return {} end
local fields = {
{ type = "header", name = "Custom Text" },
{
type = "code", name = "Custom Text Function", key = "customText", height = 160,
-- Raw, not `or ""`: nil is what tells the renderer this has never been
-- configured and should open at the default below.
get = function() return data.customText end,
set = function(v) data.customText = v; WA.Add(data, true) end,
default = CUSTOM_TEXT_DEFAULT,
-- Asked of the compiler for its wrapper rather than spelling one here:
-- two spellings drift, and the symptom is an error line number silently
-- off by one.
validate = function(txt)
return WA.Widgets.LuaSyntaxError(WA.WrapFunctionSource(txt), "custom text")
end,
},
{
type = "select", name = "Update on", key = "customTextUpdate",
values = { "event", "update" },
labels = { event = "Every state change", update = "Every frame" },
get = function() return data.customTextUpdate or "event" end,
set = function(v)
data.customTextUpdate = v
WA.Add(data, true)
-- Repaints the tab: the throttle below decides nothing outside the
-- per-frame mode.
WA.RefreshOptions()
end,
},
}
if data.customTextUpdate == "update" then
table.insert(fields, {
type = "range", name = "Throttle (seconds)", key = "customTextUpdateThrottle",
min = 0, max = 2, step = 0.05,
get = function() return data.customTextUpdateThrottle or CUSTOM_TEXT_THROTTLE end,
set = function(v) data.customTextUpdateThrottle = v; WA.Add(data, true) end,
})
end
return fields
end
-- Idle sub-region instances, per owning region and keyed by type. **Frames
-- cannot be destroyed on this client**, so an instance displaced from its slot
-- has to stay reachable: deleting one effect shifts every later one up a slot,
-- and each shift past a differently-typed neighbour would otherwise strand an
-- instance nothing can ever reach again.
--
-- The pool is per-region, not global, and must stay that way: a sub-region's
-- create closes over its parent (subtick goes further and builds its texture on
-- the parent's bar frame), so an instance is only ever reusable under the region
-- it was made for.
local function parkSubRegion(region, inst)
if inst.Hide then inst:Hide() end
local free = region.subRegionPool[inst.subType]
if not free then
free = {}
region.subRegionPool[inst.subType] = free
end
table.insert(free, inst)
end
local function takeSubRegion(region, subType)
local free = region.subRegionPool[subType]
if free and table.getn(free) > 0 then return table.remove(free) end
return nil
end
-- Rebuilds a region's sub-region instances from data.subRegions and re-wires
-- their event subscriptions (§8). Called at the end of each region type's
-- modify, so config edits, a new state, and a regionType switch all funnel
-- through one place. Instances are reused in place by index+type across edits
-- so a slider drag doesn't leak a FontString per tick, and displaced ones go to
-- the pool above rather than being dropped. Clone pooling reuses the whole
-- owning region frame; it does not detach individual sub-regions.
function proto.modifyFinish(region, data)
local setWidth, setHeight = region.SetRegionWidth, region.SetRegionHeight
if setWidth and setHeight and not WA.IsGroup(data) then
region.configWidth = region.configWidth or data.width
region.configHeight = region.configHeight or data.height
region.scaleX, region.scaleY = region.scaleX or 1, region.scaleY or 1
function region:SetRegionWidth(width)
self.configWidth = width
setWidth(self, math.max(math.abs(width * (self.scaleX or 1)), 0.01))
end
function region:SetRegionHeight(height)
self.configHeight = height
setHeight(self, math.max(math.abs(height * (self.scaleY or 1)), 0.01))
end
function region:Scale(x, y)
self.scaleX, self.scaleY = x or 1, y or 1
self:SetRegionWidth(self.configWidth or data.width)
self:SetRegionHeight(self.configHeight or data.height)
end
region:SetRegionWidth(data.width)
region:SetRegionHeight(data.height)
else
region.Scale = nil
end
local setColor = region.Color
if setColor then
local color = data.color or data.text_color or data.barColor or data.foregroundColor or { 1, 1, 1, 1 }
region.configColorR, region.configColorG = color[1], color[2]
region.configColorB, region.configColorA = color[3], color[4] or 1
function region:Color(r, g, b, a)
self.configColorR, self.configColorG = r, g
self.configColorB, self.configColorA = b, a or 1
setColor(self, self.colorAnimR or r, self.colorAnimG or g,
self.colorAnimB or b, self.colorAnimA or (a or 1))
end
function region:ColorAnim(r, g, b, a)
self.colorAnimR, self.colorAnimG = r, g
self.colorAnimB, self.colorAnimA = b, a
setColor(self, r or self.configColorR, g or self.configColorG,
b or self.configColorB, a or self.configColorA)
end
function region:GetColor()
return self.configColorR, self.configColorG, self.configColorB, self.configColorA
end
region:Color(color[1], color[2], color[3], color[4])
else
region.ColorAnim, region.GetColor = nil, nil
end
-- A clone parked behind a *former* parent's limit has nobody left to release
-- it once it's reconfigured under new ownership -- the dynamic group that owns
-- it now re-applies its own limit on the relayout that follows every WA.Add.
region:SetLimited(false)
region.subRegionEvents:Clear()
applyCustomText(region, data)
-- Ahead of every sub-region's own subscription, because subscribers fire in
-- the order they were added: a subtext resolving %c on a frame tick has to
-- read a value refreshed on *this* frame rather than the previous one.
if region.customTextFunc and region.customTextMode == "update" then
region.subRegionEvents:AddSubscriber("FrameTick", region.customTextTick)
end
region.subRegions = region.subRegions or {}
region.subRegionPool = region.subRegionPool or {}
local list = data.subRegions or {}
local n = table.getn(list)
for i = 1, n do
local subData = list[i]
local spec = WA.subRegionTypes[subData.type]
-- A subregion whose type doesn't support this display's region type
-- (e.g. a glow left on a display switched icon->bar) is hidden rather
-- than built, so the instance survives a switch back without erroring.
if spec and (not spec.supports or spec.supports(data.regionType)) then
local inst = region.subRegions[i]
if not inst or inst.subType ~= subData.type then
if inst then parkSubRegion(region, inst) end
inst = takeSubRegion(region, subData.type)
if not inst then
inst = spec.create(region)
inst.subType = subData.type
end
region.subRegions[i] = inst
end
if inst.Show then inst:Show() end
spec.modify(region, inst, data, subData)
-- After modify, not before: a type that rebuilds or re-parents its
-- frames there would otherwise be told a level and then discard it.
-- Optional because not every type has a frame to put on one --
-- subtick draws on the parent's bar and rides with the spark.
if inst.SetFrameLevel then
inst:SetFrameLevel(proto.SubRegionLevel(region, i))
end
else
local inst = region.subRegions[i]
if inst and inst.Hide then inst:Hide() end
end
end
-- Park instances left over from a shorter config. Bounded by the high-water
-- mark rather than table.getn: an unsupported type at index 1 never gets an
-- instance, and getn over the resulting hole reports 0, which would skip the
-- sweep entirely and leave a stale instance drawn.
local high = region.subRegionHigh or 0
if n > high then high = n end
for i = n + 1, high do
local inst = region.subRegions[i]
if inst then
parkSubRegion(region, inst)
region.subRegions[i] = nil
end
end
region.subRegionHigh = n
proto.RefreshFrameTick(region)
end
-- Re-derives whether anything on this region wants a per-frame repaint and moves
-- it in or out of the shared tick set. Separate from modifyFinish because a
-- condition can swap a region's whole text after the fact: a string that gains a
-- %p has to start ticking without a re-modify, and one that loses it has to stop.
function proto.RefreshFrameTick(region)
local ft = region.subRegionEvents.subs["FrameTick"]
region._hasFrameTick = (ft and table.getn(ft) > 0) or false
if region:IsShown() then
if region._hasFrameTick then proto.RegisterForFrameTick(region)
else proto.UnregisterForFrameTick(region) end
end
end
-- The adjusted-min/max arithmetic (region:SetAdjustedMin/Max, both a raw
-- number or nil plus a relative-percent fallback), shared by every progress
-- source below -- automatic, manual and per-trigger all clamp the same way.
local function applyStatic(region, value, total)
region.progressType = "static"
value = value or 0
total = total or 0
local adjustMin
if region.adjustedMin then adjustMin = region.adjustedMin
elseif region.adjustedMinRelPercent then adjustMin = region.adjustedMinRelPercent * total
else adjustMin = 0 end
local max
if region.adjustedMax then max = region.adjustedMax
elseif region.adjustedMaxRelPercent then max = region.adjustedMaxRelPercent * total
else max = total end
region.minProgress, region.maxProgress = adjustMin, max
region.value = value - adjustMin
region.total = max - adjustMin
region.paused = false
region.remaining = nil
if region.UpdateValue then region:UpdateValue() end
end
-- paused/remaining ride along separately from duration/expirationTime rather
-- than through a re-anchored expirationTime (upstream's approach): the bar
-- and progress-texture regions animate off region.expirationTime in their own
-- per-frame OnUpdate, so re-anchoring once would still visibly drain between
-- applies. UpdateTime freezes explicitly instead of computing a fake
-- expirationTime that only holds still until the next frame.
local function applyTimed(region, duration, expirationTime, paused, remaining)
region.progressType = "timed"
duration = duration or 0
expirationTime = expirationTime or 0
local adjustMin
if region.adjustedMin then adjustMin = region.adjustedMin
elseif region.adjustedMinRelPercent then adjustMin = region.adjustedMinRelPercent * duration
else adjustMin = 0 end
local max
if duration == 0 then max = 0
elseif region.adjustedMax then max = region.adjustedMax
elseif region.adjustedMaxRelPercent then max = region.adjustedMaxRelPercent * duration
else max = duration end
region.minProgress, region.maxProgress = adjustMin, max
region.duration = max - adjustMin
region.expirationTime = expirationTime - adjustMin
region.paused = paused and true or false
region.remaining = (type(remaining) == "number" and remaining) or (paused and 0) or nil
if region.UpdateTime then region:UpdateTime() end
end
-- Shared progress resolver (§7 UpdateProgressFrom). region.progressSource
-- picks which table drives the fill: -1 automatic (region.state, the active
-- trigger), 0 manual (region.progressSourceManualValue/Total), N > 0 trigger
-- N's state (region.states[N], filled by every apply regardless of which
-- trigger is active -- StateMachine.lua's ApplyStatesToRegions).
function proto.UpdateProgress(region)
local source = region.progressSource or -1
if source == 0 then
local value = region.progressSourceManualValue
if type(value) ~= "number" then value = 0 end
local total = region.progressSourceManualTotal
if type(total) ~= "number" then total = 100 end
applyStatic(region, value, total)
return
end
local state
if source > 0 then state = region.states and region.states[source]
else state = region.state end
-- A region's *visibility* is driven by the active trigger, not by its
-- progress source -- when the chosen trigger has no state, the display
-- isn't shown at all, so there's nothing to clear here. Inventing a
-- cleared/zeroed fill for a hidden region would be dead work at best and
-- a flash of "0%" at worst if it's ever shown before this trigger fires.
if not state then return end
region.stateInverse = state.inverse and true or false
if state.progressType == "timed" then
applyTimed(region, state.duration, state.expirationTime, state.paused, state.remaining)
else
applyStatic(region, state.value, state.total)
end
end
-- Native cooldown swipe (the radial spiral). The one place the client-specific
-- construction and the scale compensation live, so region types just call these
-- three. On this client the swipe is a 3D Model inheriting CooldownFrameTemplate
-- -- CreateFrame("Cooldown", ...) throws "Unknown frame type" here (Debug.lua's
-- /wa cdtest), the vanilla technique CooldownTracker also uses. pcall-guarded: a
-- client missing the template gets a nil swipe and every helper below no-ops, so
-- callers never branch on availability.
--
-- It's a square 3D asset -- confirmed in-game that neither non-uniform
-- stretching (Frame:SetScale has never taken separate x/y factors on any WoW
-- client) nor a ScrollFrame-clipped oversized-and-centered version reads
-- right, so this sticks to what's actually confirmed working: always a
-- SQUARE swipe, sized to the SMALLER of width/height (never overflows) and
-- centered in the region. For a non-square icon this leaves a gap on the
-- longer axis rather than covering it -- an accepted tradeoff, not solved.
function proto.CreateSwipe(parent)
local ok, swipe = pcall(CreateFrame, "Model", nil, parent, "CooldownFrameTemplate")
if not ok or not swipe then return nil end
swipe:Hide()
return swipe
end
-- The Model is authored for a 36-unit frame (confirmed against pfUI's own
-- working Model+CooldownFrameTemplate swipe, modules/cooldown.lua's
-- SetCooldown -- size/32 left a visible sliver of the icon at the edges), so
-- it underfills and sits bottom-left unless its frame is scaled size/36. The
-- two-corner anchor is the centered-square placement (dw/dh account for a
-- non-square parent) plus the empirical alignment nudge (this Model's
-- rendered content sits very slightly left/down of its scaled frame bounds)
-- both expressed at once. Tunable live via Debug.lua's /wa swipenudge (no
-- /reload needed -- plain table fields, re-read by SizeSwipe on every call).
-- swipeNudgeK is a proportional coefficient (nudge = swipeNudgeK * size);
-- swipeYFlat is a flat additional vertical offset that empirically does NOT
-- scale with size the same way. Values below tuned in-game across
-- 16/32/64/128px via /wa swipetest -- clean at 32-128px; 16px still shows a
-- hairline gap and wasn't worth chasing further (tabled, not fixed -- revisit
-- with /wa swipetest 16 if a future icon skin actually ships that small).
proto.swipeNudgeK = 0.0625
proto.swipeYFlat = -0.25
function proto.SizeSwipe(swipe, width, height)
if not swipe then return end
width = width or 32
height = height or width
local size = math.min(width, height)
swipe:SetScale(size / 36)
swipe:ClearAllPoints()
local dw, dh = (width - size) / 2, (height - size) / 2
local nudge = proto.swipeNudgeK * size
local testY = proto.swipeYFlat
swipe:SetPoint("TOPLEFT", swipe:GetParent(), "TOPLEFT", dw, -dh + nudge + testY)
swipe:SetPoint("BOTTOMRIGHT", swipe:GetParent(), "BOTTOMRIGHT", -dw + nudge, dh + testY)
end
-- Arm the swipe from a timed state, or clear+hide it when duration <= 0.
-- CooldownFrame_SetTimer wants the *start* time, so back it out of expiration.
function proto.ArmSwipe(swipe, expirationTime, duration)
if not swipe then return end
if duration and duration > 0 then
swipe:Show()
CooldownFrame_SetTimer(swipe, (expirationTime or 0) - duration, duration, 1)
else
CooldownFrame_SetTimer(swipe, 0, 0, 0)
swipe:Hide()
end
end
-- Frame levels inside a region, relative to the region's own. A child frame's
-- draw layers all sit above its parent's, so anything a region type builds as a
-- child (a progress bar, the icon's cooldown swipe) would otherwise cover text
-- or a border created on the region. Region types keep their internals below
-- SUB_LEVEL; subregions sit at or above it, ordered among themselves.
proto.SUB_LEVEL = 5
-- Levels reserved per subregion, so a type needing two frames of its own has one
-- to spare without landing on its neighbour's. subglow is the reason: its
-- backdrop and its art must be ordered against each other, and at a step of 1
-- the art would tie with whatever sits one slot higher in the list, leaving the
-- winner to creation order rather than to what the user arranged.
proto.SUB_STEP = 2
-- The draw level of the i-th entry of data.subRegions, in list order: the first
-- effect sits lowest, the last on top. Type no longer decides -- moving a row in
-- the Display Effects list is what restacks it.
function proto.SubRegionLevel(region, index)
return region:GetFrameLevel() + proto.SUB_LEVEL + (index - 1) * proto.SUB_STEP
end
-- Area-anchors a subregion frame over the whole parent region (border/glow
-- cover the region rather than self-anchoring to one point the way subtext
-- does, §8 anchor_area). inset grows(+)/shrinks(-) the covered rect.
function proto.AnchorArea(region, parent, inset)
inset = inset or 0
region:ClearAllPoints()
region:SetPoint("TOPLEFT", parent, "TOPLEFT", -inset, inset)
region:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", inset, -inset)
end
local function subRegionAnchorValue(parentData, subData, key, fallback)
local value = subData[key]
if value ~= nil then return value end
return fallback
end
function proto.GetSubRegionAnchorTarget(parent, key)
if parent.GetSubAnchorTarget then
return parent:GetSubAnchorTarget(key)
end
return parent
end
function proto.GetSubRegionAnchorPoint(key, fallback)
if not key or key == "region" or key == "bar" or key == "icon" or key == "fg" or key == "bg" or key == "SPARK" then
return fallback or "CENTER"
end
local point = string.gsub(key, "^ICON_", "")
point = string.gsub(point, "^INNER_", "")
point = string.gsub(point, "^OUTER_", "")
return point
end
local POINTS = {
CENTER = true, TOP = true, BOTTOM = true, LEFT = true, RIGHT = true,
TOPLEFT = true, TOPRIGHT = true, BOTTOMLEFT = true, BOTTOMRIGHT = true,
}
function proto.IsAnchorPoint(value)
return POINTS[value] == true
end
-- SetPoint raises "Unknown region point" on anything outside those nine, and the
-- error escapes through whatever repaint it was in -- selection preview included,
-- which is how a bad anchor makes an aura unclickable rather than merely
-- misplaced. Saved data can hold a combined key ("OUTER_TOPLEFT", "SPARK") where
-- a bare point belongs, so nothing reaches SetPoint without passing through here.
function proto.ResolveAnchorPoint(value, fallback)
fallback = POINTS[fallback] and fallback or "CENTER"
if POINTS[value] then return value end
if type(value) ~= "string" then return fallback end
local point = proto.GetSubRegionAnchorPoint(value, fallback)
return POINTS[point] and point or fallback
end
local INVERSE_POINTS = {
TOPLEFT = "BOTTOMRIGHT", TOP = "BOTTOM", TOPRIGHT = "BOTTOMLEFT",
LEFT = "RIGHT", CENTER = "CENTER", RIGHT = "LEFT",
BOTTOMLEFT = "TOPRIGHT", BOTTOM = "TOP", BOTTOMRIGHT = "TOPLEFT",
}
-- The self point upstream's AUTO derives (WA2 SubText.lua): on an icon it reads
-- the anchored part -- inside keeps the point, outside inverts it so the text
-- sits clear of the edge -- a bar keeps the point, anything else inverts. Ours
-- stores a real self point instead of resolving AUTO at paint time, so this is
-- what fills it in when the anchor comes from upstream.
function proto.AutoSelfPoint(anchorKey, point, regionType)
point = proto.ResolveAnchorPoint(point, "CENTER")
if regionType == "icon" then
if type(anchorKey) == "string" then
if string.sub(anchorKey, 1, 6) == "INNER_" then return point end
if string.sub(anchorKey, 1, 6) == "OUTER_" then return INVERSE_POINTS[point] end
end
return "CENTER"
end
if regionType == "progressbar" then return point end
return INVERSE_POINTS[point]
end
function proto.GetSubRegionAnchors(parentData, mode)
local spec = WA.RegionSpecFor(parentData)
local anchors = spec and spec.getSubRegionAnchors and spec.getSubRegionAnchors(parentData) or {}
local copy = { region = { display = "Whole region", point = true, area = true } }
for key, anchor in pairs(anchors) do copy[key] = anchor end
anchors = copy
local values, labels = {}, {}
for key, anchor in pairs(anchors) do
if anchor[mode] then
table.insert(values, key)
labels[key] = anchor.display or key
end
end
table.sort(values)
return values, labels
end
function proto.AnchorSubRegion(frame, parent, subData, defaults)
defaults = defaults or {}
local mode = defaults.areaOnly and "area" or (subData.anchor_mode or defaults.mode or "point")
local targetKey
local targetPoint
local selfPoint
local xOffset
local yOffset
if mode == "area" then
targetKey = subRegionAnchorValue(parent, subData, "anchor_area", defaults.areaTarget or "region")
xOffset = subRegionAnchorValue(parent, subData, "anchorXOffset", subData.border_offset or defaults.x or 0)
yOffset = subRegionAnchorValue(parent, subData, "anchorYOffset", subData.border_offset or defaults.y or 0)
else
targetKey = subRegionAnchorValue(parent, subData, "anchor_target", defaults.target or "region")
-- Upstream stores the anchored part and the point in one value; ours splits
-- them across anchor_target and anchor_point. A combined value sitting in
-- anchor_point therefore names the part too, unless a target was picked
-- separately.
if not subData.anchor_target and type(subData.anchor_point) == "string"
and not proto.IsAnchorPoint(subData.anchor_point) then
targetKey = subData.anchor_point
end
if subData.anchor_point then
targetPoint = subData.anchor_point
elseif subData.anchor_target then
targetPoint = proto.GetSubRegionAnchorPoint(targetKey, defaults.anchorPoint)
else
targetPoint = subRegionAnchorValue(parent, subData, "anchor_point", defaults.anchorPoint or "CENTER")
end
selfPoint = subRegionAnchorValue(parent, subData, "self_point", defaults.selfPoint or targetPoint or "CENTER")
targetPoint = proto.ResolveAnchorPoint(targetPoint, "CENTER")
selfPoint = proto.ResolveAnchorPoint(selfPoint, targetPoint)
xOffset = subRegionAnchorValue(parent, subData, "anchorXOffset", defaults.x or 0)
yOffset = subRegionAnchorValue(parent, subData, "anchorYOffset", defaults.y or 0)
end
local target = proto.GetSubRegionAnchorTarget(parent, targetKey)
if not target then target = parent end
frame:ClearAllPoints()
if mode == "area" then
frame:SetPoint("TOPLEFT", target, "TOPLEFT", -xOffset, yOffset)
frame:SetPoint("BOTTOMRIGHT", target, "BOTTOMRIGHT", xOffset, -yOffset)
else
frame:SetPoint(selfPoint or "CENTER", target, targetPoint or "CENTER", xOffset, yOffset)
end
end
function proto.SubRegionAnchorFields(parentData, subData, defaults)
defaults = defaults or {}
local fields = {}
local modeField = {
type = "select", name = "Anchor mode", key = "anchor_mode",
values = { "point", "area" },
labels = { point = "Point", area = "Area" },
get = function() return subData.anchor_mode or defaults.mode or "point" end,
set = function(v)
subData.anchor_mode = v
WA.Add(parentData, true)
WA.RefreshOptions()
end,
}
local mode = defaults.areaOnly and "area" or (subData.anchor_mode or defaults.mode or "point")
if mode == "area" then
if not defaults.areaOnly then table.insert(fields, modeField) end
local values, labels = proto.GetSubRegionAnchors(parentData, "area")
table.insert(fields, {
type = "select", name = "Area", key = "anchor_area", values = values, labels = labels,
get = function() return subData.anchor_area or defaults.areaTarget or "region" end,
set = function(v) subData.anchor_area = v; WA.Add(parentData, true) end,
})
else
local values, labels = proto.GetSubRegionAnchors(parentData, "point")
local targetField = {
type = "select", name = "Target", key = "anchor_target", values = values, labels = labels,
get = function() return subData.anchor_target or defaults.target or "region" end,
set = function(v)
subData.anchor_target = v
local point = proto.GetSubRegionAnchorPoint(v)
subData.anchor_point, subData.self_point = point, point
WA.Add(parentData, true)
end,
}
table.insert(fields, {
type = "anchorlayout", grid = {
type = "anchorgrid", name = "Anchor", key = "self_point",
values = proto.anchorGridPoints, width = 100, height = 50,
get = function() return subData.self_point or defaults.selfPoint or "CENTER" end,
set = function(v)
subData.anchor_point, subData.self_point = v, v
WA.Add(parentData, true)
end,
},
sideFields = { modeField, targetField },
})
end
table.insert(fields, {
type = "range", name = mode == "area" and "Extra width" or "X", key = "anchorXOffset", half = true,
min = -200, max = 200, step = 1,
get = function() return subData.anchorXOffset or defaults.x or 0 end,
set = function(v) subData.anchorXOffset = v; WA.Add(parentData, true) end,
})
table.insert(fields, {
type = "range", name = mode == "area" and "Extra height" or "Y", key = "anchorYOffset", half = true,
min = -200, max = 200, step = 1, half = true,
get = function() return subData.anchorYOffset or defaults.y or 0 end,
set = function(v) subData.anchorYOffset = v; WA.Add(parentData, true) end,
})
return fields
end
-- Injects the universal conditionable properties into a region type's registry
-- (§7 AddProperties). Lives beside the setters it names so the two stay honest.
function proto.AddProperties(properties)
properties.sound = { display = "Sound", action = "SoundPlay", type = "sound" }
properties.chat = { display = "Chat Message", action = "SendChat", type = "chat" }
properties.customcode = { display = "Run Custom Code", action = "RunCode", type = "customcode" }
properties.glowexternal = { display = "Glow External Element", action = "GlowExternal", type = "glowexternal" }
properties.alpha = { display = "Alpha", setter = "SetRegionAlpha", type = "number", min = 0, max = 1, step = 0.05 }
-- These are *relative* deltas only conditions set (no data.<key> backing
-- them), so their restored base is an explicit 0, not data[key] (§7).
properties.xOffsetRelative = { display = "X Offset", setter = "SetXOffsetRelative", type = "number", min = -200, max = 200, step = 1, base = 0 }
properties.yOffsetRelative = { display = "Y Offset", setter = "SetYOffsetRelative", type = "number", min = -200, max = 200, step = 1, base = 0 }
return properties
end
-- Separate from AddProperties: only progress region types (icon, progressbar,
-- progresstexture) have adjusted min/max to condition on, so this isn't folded
-- into the universal set a future non-progress region type would also inherit.
function proto.AddProgressProperties(properties)
properties.adjustedMin = { display = "Minimum Progress", setter = "SetAdjustedMin", type = "string" }
properties.adjustedMax = { display = "Maximum Progress", setter = "SetAdjustedMax", type = "string" }
return properties
end
-- The nine anchor tokens every region's self/anchor point picks from.
proto.anchorPoints = { "CENTER", "TOP", "BOTTOM", "LEFT", "RIGHT",
"TOPLEFT", "TOPRIGHT", "BOTTOMLEFT", "BOTTOMRIGHT" }
proto.anchorGridPoints = { "TOPLEFT", "TOP", "TOPRIGHT", "LEFT", "CENTER", "RIGHT",
"BOTTOMLEFT", "BOTTOM", "BOTTOMRIGHT" }
-- Upstream's frame_strata_types (WA2 Types.lua): 1-based, where 1 is
-- "Inherited" rather than a real strata. Shared by the Frame strata select
-- (labels) and ApplyFrameStrata (lookup) so the two can't disagree.
local FRAME_STRATA_NAMES = {
[1] = "Inherited",
[2] = "BACKGROUND",
[3] = "LOW",
[4] = "MEDIUM",
[5] = "HIGH",
[6] = "DIALOG",
[7] = "FULLSCREEN",
[8] = "FULLSCREEN_DIALOG",
[9] = "TOOLTIP",
}
local FRAME_STRATA_VALUES = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }
-- The names SetFrameStrata will take, for checking one that came back off a
-- frame rather than out of the table above.
local REAL_STRATA = {}
for i = 2, table.getn(FRAME_STRATA_VALUES) do REAL_STRATA[FRAME_STRATA_NAMES[i]] = true end
local ANCHOR_FRAME_TYPES = { "SCREEN", "UIPARENT", "SELECTFRAME", "MOUSE", "NAMEPLATE", "UNITFRAME", "CUSTOM" }
local ANCHOR_FRAME_LABELS = {
SCREEN = "Screen / group",
UIPARENT = "UIParent",
SELECTFRAME = "Selected frame",
MOUSE = "Mouse",
NAMEPLATE = "Nameplate",
UNITFRAME = "Unit frame",
CUSTOM = "Custom",
}
local ANCHOR_FRAME_VALUES = { "SCREEN", "UIPARENT", "SELECTFRAME", "MOUSE", "NAMEPLATE", "UNITFRAME", "CUSTOM" }
local hiddenFrames
local pendingAnchorRetries = {}
local anchorRetryScheduled
local mouseAnchorFrame
local optionsNameplateAnchorFrame
local mouseAnchorMarker