-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLibAnimate.lua
More file actions
1340 lines (1191 loc) · 51 KB
/
LibAnimate.lua
File metadata and controls
1340 lines (1191 loc) · 51 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
-------------------------------------------------------------------------------
-- LibAnimate
-- Keyframe-driven animation library for World of Warcraft frames
-- Inspired by animate.css (https://animate.style)
--
-- Supported versions: Retail, TBC Anniversary, MoP Classic
-------------------------------------------------------------------------------
local MAJOR, MINOR = "LibAnimate", 1
local lib = LibStub:NewLibrary(MAJOR, MINOR)
if not lib then return end
-------------------------------------------------------------------------------
-- Type Definitions
-------------------------------------------------------------------------------
---@class LibAnimate
---@field animations table<string, AnimationDefinition> Registered animation definitions
---@field activeAnimations table<Frame, AnimationState> Currently running animations
---@field easings table<string, fun(t: number): number> Named easing presets
---@field CubicBezier fun(p1x: number, p1y: number, p2x: number, p2y: number): fun(t: number): number
---@field ApplyEasing fun(easing: EasingSpec, t: number): number
---@class AnimationDefinition
---@field type "entrance"|"exit"|"attention" Animation category
---@field defaultDuration number? Default duration in seconds
---@field defaultDistance number? Default translation distance in pixels
---@field keyframes Keyframe[] Ordered list of keyframes (progress 0.0 to 1.0)
---@class Keyframe
---@field progress number Normalized time position (0.0 to 1.0)
---@field translateX number? Horizontal offset as fraction of distance (default 0)
---@field translateY number? Vertical offset as fraction of distance (default 0)
---@field scale number? Uniform scale factor (default 1.0)
---@field alpha number? Opacity (default 1.0)
---@field easing EasingSpec? Easing for the segment STARTING at this keyframe
--- A named preset (e.g. "easeOutCubic") or cubic-bezier control points {p1x, p1y, p2x, p2y}.
---@alias EasingSpec string|number[]
---@class AnimateOpts
---@field duration number? Override animation duration in seconds
---@field distance number? Override translation distance in pixels
---@field delay number? Delay in seconds before animation starts (default 0)
---@field repeatCount number? Number of times to play (0 = infinite, nil/1 = once)
---@field onFinished fun(frame: Frame)? Callback fired when the animation completes naturally
--- Configuration for a single step in an animation queue.
---@class QueueEntry
---@field name string Animation name
---@field duration number? Duration override in seconds
---@field distance number? Distance override in pixels
---@field delay number? Delay before this step starts in seconds
---@field repeatCount number? Repeat count for this step (0 = infinite)
---@field onFinished fun(frame: Frame)? Callback when this step completes
--- Options for the animation queue.
---@class QueueOpts
---@field onFinished fun(frame: Frame)? Called when the entire sequence completes
---@field loop boolean? If true, restart from entry 1 after the last entry. onFinished is not called while looping.
---@class AnimationState
---@field definition AnimationDefinition
---@field keyframes Keyframe[]
---@field startTime number GetTime() at animation start
---@field duration number Active duration in seconds
---@field distance number Translation distance in pixels
---@field delay number Delay in seconds before animation starts
---@field repeatCount number Number of total repeats (0 = infinite, 1 = once)
---@field currentRepeat number Current repeat iteration (starts at 1)
---@field onFinished fun(frame: Frame)?
---@field anchorPoint string Captured anchor point
---@field anchorRelativeTo Frame? Captured relative-to frame
---@field anchorRelativePoint string Captured relative point
---@field anchorX number Captured anchor X offset
---@field anchorY number Captured anchor Y offset
---@field originalScale number Pre-animation scale
---@field originalAlpha number Pre-animation alpha
---@field hasScale boolean Whether the animation defines scale keyframes
---@field hasTranslate boolean Whether the animation defines translate keyframes
---@field resolvedEasings table<integer, fun(t: number): number>
-------------------------------------------------------------------------------
-- Cached Globals
-------------------------------------------------------------------------------
local GetTime = GetTime
local CreateFrame = CreateFrame
local geterrorhandler = geterrorhandler
local pcall = pcall
local pairs = pairs
local next = next
local ipairs = ipairs
local type = type
local math_min = math.min
local math_abs = math.abs
local math_floor = math.floor
local table_sort = table.sort
local table_insert = table.insert
local table_remove = table.remove
-------------------------------------------------------------------------------
-- State Initialization
-------------------------------------------------------------------------------
lib.animations = lib.animations or {}
lib.activeAnimations = lib.activeAnimations or {}
lib.animationQueues = lib.animationQueues or {}
if not lib.driverFrame then
lib.driverFrame = CreateFrame("Frame")
lib.driverFrame:Hide()
end
local driverFrame = lib.driverFrame
-------------------------------------------------------------------------------
-- Easing Functions
-------------------------------------------------------------------------------
--- Named easing presets mapping string names to easing functions.
--- Each function takes a normalized time `t` in [0, 1] and returns the eased value.
---
--- Available presets:
--- - `"linear"` — No easing
--- - `"easeInQuad"`, `"easeOutQuad"`, `"easeInOutQuad"` — Quadratic
--- - `"easeInCubic"`, `"easeOutCubic"`, `"easeInOutCubic"` — Cubic
--- - `"easeInBack"`, `"easeOutBack"`, `"easeInOutBack"` — Back (overshoot)
---@type table<string, fun(t: number): number>
lib.easings = {
linear = function(t)
return t
end,
easeInQuad = function(t)
return t * t
end,
easeOutQuad = function(t)
return 1 - (1 - t) * (1 - t)
end,
easeInOutQuad = function(t)
if t < 0.5 then
return 2 * t * t
end
return 1 - (-2 * t + 2) * (-2 * t + 2) / 2
end,
easeInCubic = function(t)
return t * t * t
end,
easeOutCubic = function(t)
local inv = 1 - t
return 1 - inv * inv * inv
end,
easeInOutCubic = function(t)
if t < 0.5 then
return 4 * t * t * t
end
local inv = -2 * t + 2
return 1 - inv * inv * inv / 2
end,
easeInBack = function(t)
local c1 = 1.70158
local c3 = c1 + 1
return c3 * t * t * t - c1 * t * t
end,
easeOutBack = function(t)
local c1 = 1.70158
local c3 = c1 + 1
local inv = t - 1
return 1 + c3 * inv * inv * inv + c1 * inv * inv
end,
easeInOutBack = function(t)
local c1 = 1.70158
local c2 = c1 * 1.525
if t < 0.5 then
return ((2 * t) * (2 * t) * ((c2 + 1) * (2 * t) - c2)) / 2
end
local inv = 2 * t - 2
return (inv * inv * ((c2 + 1) * inv + c2) + 2) / 2
end,
}
-------------------------------------------------------------------------------
-- Cubic-Bezier Solver
-------------------------------------------------------------------------------
--- Creates a cubic-bezier easing function from four control points.
--- Uses Newton-Raphson iteration with binary-search fallback.
---@param p1x number X of first control point (0-1)
---@param p1y number Y of first control point
---@param p2x number X of second control point (0-1)
---@param p2y number Y of second control point
---@return fun(t: number): number easingFn Easing function mapping [0,1] to [0,1]
local function CubicBezier(p1x, p1y, p2x, p2y)
--- Evaluates the X component of the cubic bezier at parameter t.
---@param t number Bezier parameter (0-1)
---@return number x X coordinate on the bezier curve
local function sampleCurveX(t)
return (((1 - 3 * p2x + 3 * p1x) * t + (3 * p2x - 6 * p1x)) * t + 3 * p1x) * t
end
--- Evaluates the Y component (output value) of the cubic bezier at parameter t.
---@param t number Bezier parameter (0-1)
---@return number y Y coordinate on the bezier curve
local function sampleCurveY(t)
return (((1 - 3 * p2y + 3 * p1y) * t + (3 * p2y - 6 * p1y)) * t + 3 * p1y) * t
end
--- Evaluates the derivative of the X component at parameter t (for Newton-Raphson).
---@param t number Bezier parameter (0-1)
---@return number dx Derivative of X with respect to t
local function sampleCurveDerivativeX(t)
return (3 * (1 - 3 * p2x + 3 * p1x) * t + 2 * (3 * p2x - 6 * p1x)) * t + 3 * p1x
end
--- Finds the bezier parameter t that produces a given X value.
--- Uses Newton-Raphson iteration (8 steps) with binary-search fallback (20 steps).
---@param x number Target X value (0-1)
---@return number t Bezier parameter that maps to x
local function solveCurveX(x)
-- Newton-Raphson
local t = x
for _ = 1, 8 do
local currentX = sampleCurveX(t) - x
if math_abs(currentX) < 1e-6 then
return t
end
local dx = sampleCurveDerivativeX(t)
if math_abs(dx) < 1e-6 then
break
end
t = t - currentX / dx
end
-- Binary search fallback
local lo, hi = 0.0, 1.0
t = x
for _ = 1, 20 do
local currentX = sampleCurveX(t)
if math_abs(currentX - x) < 1e-6 then
return t
end
if currentX > x then
hi = t
else
lo = t
end
t = (lo + hi) * 0.5
end
return t
end
--- Returned easing function: maps a normalized progress value through the bezier curve.
---@param x number Normalized progress (0-1), clamped at boundaries
---@return number y Eased progress value
return function(x)
if x <= 0 then return 0 end
if x >= 1 then return 1 end
return sampleCurveY(solveCurveX(x))
end
end
lib.CubicBezier = CubicBezier
-------------------------------------------------------------------------------
-- ApplyEasing Helper
-------------------------------------------------------------------------------
--- Applies an easing function to a progress value.
--- Accepts a named preset string or a cubic-bezier control point table.
--- This is a utility function; the hot-path uses pre-resolved easing functions instead.
---@param easing EasingSpec Easing preset name or {p1x, p1y, p2x, p2y}
---@param t number Normalized progress (0-1)
---@return number easedT Eased progress value
local function ApplyEasing(easing, t)
if type(easing) == "string" then
local fn = lib.easings[easing]
if fn then return fn(t) end
return t
elseif type(easing) == "table" then
local fn = CubicBezier(easing[1], easing[2], easing[3], easing[4])
return fn(t)
end
return t
end
lib.ApplyEasing = ApplyEasing
-------------------------------------------------------------------------------
-- Keyframe Interpolation
-------------------------------------------------------------------------------
--- Default values for keyframe properties when not explicitly set.
--- Used by `GetProperty` to fill in missing values during interpolation.
---@type table<string, number>
local PROPERTY_DEFAULTS = {
translateX = 0,
translateY = 0,
scale = 1.0,
alpha = 1.0,
}
--- Finds the two bracketing keyframes for a given progress value.
--- Returns the start and end keyframes of the active segment, the interpolation
--- progress within that segment, and the index of the start keyframe.
---@param keyframes Keyframe[] Ordered keyframe list (progress 0.0 to 1.0)
---@param progress number Normalized animation progress (0-1)
---@return Keyframe kf1 Start keyframe of the active segment
---@return Keyframe kf2 End keyframe of the active segment
---@return number segmentProgress Interpolation progress within the segment (0-1)
---@return integer kf1Index Index of kf1 in the keyframes array
local function FindKeyframes(keyframes, progress)
-- Handle boundary cases explicitly
if progress <= 0 then
local segmentLength = keyframes[2].progress - keyframes[1].progress
local segmentProgress = 0
if segmentLength > 0 then
segmentProgress = (progress - keyframes[1].progress) / segmentLength
end
return keyframes[1], keyframes[2], segmentProgress, 1
end
local n = #keyframes
if progress >= 1.0 then
return keyframes[n - 1], keyframes[n], 1, n - 1
end
-- Search for bracketing keyframes
for i = 1, n - 1 do
if progress >= keyframes[i].progress and progress <= keyframes[i + 1].progress then
local segmentLength = keyframes[i + 1].progress - keyframes[i].progress
local segmentProgress = 0
if segmentLength > 0 then
segmentProgress = (progress - keyframes[i].progress) / segmentLength
end
return keyframes[i], keyframes[i + 1], segmentProgress, i
end
end
-- Fallback: should never reach here with valid keyframes (0.0 to 1.0 boundary)
return keyframes[n - 1], keyframes[n], 1, n - 1
end
--- Returns a keyframe property value, falling back to PROPERTY_DEFAULTS if not set.
---@param kf Keyframe The keyframe to read from
---@param name string Property name ("translateX", "translateY", "scale", or "alpha")
---@return number value The property value
local function GetProperty(kf, name)
if kf[name] ~= nil then
return kf[name]
end
return PROPERTY_DEFAULTS[name]
end
--- Linearly interpolates between two values.
---@param a number Start value
---@param b number End value
---@param t number Interpolation factor (0 = a, 1 = b)
---@return number result Interpolated value
local function Lerp(a, b, t)
return a + (b - a) * t
end
-------------------------------------------------------------------------------
-- ApplyToFrame
-------------------------------------------------------------------------------
--- Applies interpolated animation properties to a frame.
--- Only modifies properties that the animation actually defines:
--- - Alpha is always applied (backward compatible with slide animations)
--- - Translate is only applied when `state.hasTranslate` is true
--- - Scale is only applied when `state.hasScale` is true, and is relative
--- to `state.originalScale` so user-configured scale is preserved
---@param frame Frame The frame being animated
---@param state AnimationState The active animation state
---@param tx number Interpolated translateX (fraction of distance)
---@param ty number Interpolated translateY (fraction of distance)
---@param sc number Interpolated scale factor (relative to originalScale)
---@param al number Interpolated alpha (opacity)
local function ApplyToFrame(frame, state, tx, ty, sc, al)
if state.hasTranslate then
local distance = state.distance or 0
local offsetX = tx * distance
local offsetY = ty * distance
frame:ClearAllPoints()
frame:SetPoint(state.anchorPoint, state.anchorRelativeTo, state.anchorRelativePoint,
state.anchorX + offsetX, state.anchorY + offsetY)
end
if state.hasScale then
local finalScale = sc * (state.originalScale or 1)
if finalScale < 0.001 then finalScale = 0.001 end
frame:SetScale(finalScale)
end
frame:SetAlpha(al)
end
-------------------------------------------------------------------------------
-- Driver Frame OnUpdate
-------------------------------------------------------------------------------
--- Main animation driver. Runs every frame while any animation is active.
--- For each active animation: advances progress, finds bracketing keyframes,
--- applies per-segment easing, interpolates properties, and applies to the frame.
--- Completed animations are snapped to final state and their callbacks are fired
--- in a deferred pass (after all state cleanup) to prevent re-entrancy issues.
driverFrame:SetScript("OnUpdate", function()
local now = GetTime()
local toRemove = nil
for frame, state in pairs(lib.activeAnimations) do
-- Skip paused animations entirely
if state.isPaused then -- luacheck: ignore 542
-- Do nothing: animation is frozen
else
local elapsed = now - state.startTime
-- Handle delay: skip interpolation while in delay period
if elapsed < state.delay then -- luacheck: ignore 542
-- Do nothing, frame stays in its pre-animation state
else
local rawProgress = math_min(
(elapsed - state.delay) / state.duration, 1.0
)
-- Find bracketing keyframes
local kf1, kf2, segmentProgress, kf1Index =
FindKeyframes(state.keyframes, rawProgress)
-- Apply per-segment easing
if state.resolvedEasings[kf1Index] then
segmentProgress =
state.resolvedEasings[kf1Index](segmentProgress)
end
-- Interpolate properties
local easedT = segmentProgress
local tx = Lerp(
GetProperty(kf1, "translateX"),
GetProperty(kf2, "translateX"), easedT
)
local ty = Lerp(
GetProperty(kf1, "translateY"),
GetProperty(kf2, "translateY"), easedT
)
local sc = Lerp(
GetProperty(kf1, "scale"),
GetProperty(kf2, "scale"), easedT
)
local al = Lerp(
GetProperty(kf1, "alpha"),
GetProperty(kf2, "alpha"), easedT
)
-- SlideAnchor interpolation: smoothly move base anchor
if state.slideStartTime then
local slideElapsed = now - state.slideStartTime
local slideProgress = math_min(
slideElapsed / state.slideDuration, 1.0
)
state.anchorX = Lerp(
state.slideFromX, state.slideToX, slideProgress
)
state.anchorY = Lerp(
state.slideFromY, state.slideToY, slideProgress
)
-- Clear slide state when complete
if slideProgress >= 1.0 then
state.anchorX = state.slideToX
state.anchorY = state.slideToY
state.slideStartTime = nil
state.slideDuration = nil
state.slideFromX = nil
state.slideFromY = nil
state.slideToX = nil
state.slideToY = nil
end
end
-- Apply to frame
ApplyToFrame(frame, state, tx, ty, sc, al)
-- Check completion with repeat support
if rawProgress >= 1.0 then
if state.repeatCount == 0
or state.currentRepeat < state.repeatCount
then
-- Reset for next repeat (no delay between repeats)
state.startTime = now
state.delay = 0
state.currentRepeat = state.currentRepeat + 1
else
if not toRemove then toRemove = {} end
toRemove[#toRemove + 1] = frame
end
end
end
end
end
-- Process completions
if toRemove then
-- First pass: snap to final values and collect callbacks.
-- State is intentionally kept alive for frames that have an onFinished
-- callback so that Animate() -> Stop() inside the callback can restore
-- the frame to its base anchor before capturing the new anchor.
local callbacks = nil
for _, frame in ipairs(toRemove) do
local state = lib.activeAnimations[frame]
if state then
-- Snap to final state
local lastKf = state.keyframes[#state.keyframes]
local ftx = GetProperty(lastKf, "translateX")
local fty = GetProperty(lastKf, "translateY")
local fsc = GetProperty(lastKf, "scale")
local fal = GetProperty(lastKf, "alpha")
ApplyToFrame(frame, state, ftx, fty, fsc, fal)
if state.onFinished then
if not callbacks then callbacks = {} end
callbacks[#callbacks + 1] = {
fn = state.onFinished, frame = frame, state = state,
}
else
lib.activeAnimations[frame] = nil
end
end
end
-- Second pass: fire callbacks.
-- After each callback, clear the state only if the callback did not
-- start a new animation (i.e. the slot still holds the completed state).
if callbacks then
for _, cb in ipairs(callbacks) do
local ok, err = pcall(cb.fn, cb.frame)
if not ok then
geterrorhandler()(err)
end
if lib.activeAnimations[cb.frame] == cb.state then
lib.activeAnimations[cb.frame] = nil
end
end
end
end
-- Hide driver if no active animations
if not next(lib.activeAnimations) then
driverFrame:Hide()
end
end)
-------------------------------------------------------------------------------
-- Public API
-------------------------------------------------------------------------------
--- Plays a registered animation on a frame.
---
--- The frame must have exactly one anchor point set via `SetPoint()`.
--- Frames with multiple anchor points (two-point sizing) are not supported
--- and will lose their secondary anchors during animation.
---
--- If the frame is already animating, the current animation is stopped
--- (restoring the frame to its pre-animation state) before the new one starts.
---
--- Supports `delay` to wait before starting and `repeatCount` to repeat
--- (0 = infinite). If the frame has an active queue, the queue is cleared.
---
--- For exit animations, the frame is left at its final keyframe state when
--- the animation completes. The consumer must handle cleanup (e.g. `frame:Hide()`)
--- in the `onFinished` callback.
---
--- For attention-seeker animations, the frame returns to its original state
--- when the animation completes (keyframes start and end at identity values).
---
--- Usage:
--- ```lua
--- local LibAnimate = LibStub("LibAnimate")
--- LibAnimate:Animate(myFrame, "fadeIn", {
--- duration = 0.5,
--- delay = 0.2,
--- repeatCount = 3,
--- onFinished = function(frame) print("done!") end,
--- })
--- ```
---@param frame Frame The frame to animate (must have one anchor point)
---@param name string Registered animation name
---@param opts AnimateOpts? Animation options
---@return boolean success Always returns true on success; errors on invalid input
function lib:Animate(frame, name, opts)
if opts ~= nil and type(opts) ~= "table" then
error("LibAnimate: opts must be a table or nil", 2)
end
opts = opts or {}
-- Stop existing animation on this frame
if lib.activeAnimations[frame] then
self:Stop(frame)
end
-- Clear any active queue on this frame
lib.animationQueues[frame] = nil
local def = lib.animations[name]
if not def then
error("LibAnimate: Unknown animation '" .. tostring(name) .. "'", 2)
end
local duration = opts.duration or def.defaultDuration
if not duration or duration <= 0 then
error("LibAnimate: Animation duration must be greater than 0", 2)
end
-- Capture current anchor
local pt, rel, relPt, x, y = frame:GetPoint()
if not pt then
error("LibAnimate: Frame has no anchor point set", 2)
end
local originalScale = frame:GetScale()
local originalAlpha = frame:GetAlpha()
-- Pre-resolve easing functions to avoid per-tick allocation.
-- Convention: kf.easing applies to the segment STARTING at that keyframe
-- (i.e., the easing from kf[i] to kf[i+1] is defined on kf[i]).
local resolvedEasings = {}
for i, kf in ipairs(def.keyframes) do
if kf.easing then
if type(kf.easing) == "string" then
resolvedEasings[i] = lib.easings[kf.easing] or lib.easings.linear
elseif type(kf.easing) == "table" then
resolvedEasings[i] = CubicBezier(kf.easing[1], kf.easing[2], kf.easing[3], kf.easing[4])
end
end
end
-- Determine which properties the animation actually defines.
-- Alpha is always animated (backward compatible: slide animations rely
-- on the default alpha=1.0 being applied every tick).
-- Scale and translate are conditional to avoid overriding user-configured values.
local hasScale, hasTranslate = false, false
for _, kf in ipairs(def.keyframes) do
if kf.scale ~= nil then hasScale = true end
if kf.translateX ~= nil or kf.translateY ~= nil then hasTranslate = true end
end
local delay = opts.delay or 0
if type(delay) ~= "number" or delay < 0 then
error("LibAnimate: delay must be a non-negative number", 2)
end
local repeatCount = opts.repeatCount or 1
if type(repeatCount) ~= "number" or repeatCount < 0 or repeatCount ~= math_floor(repeatCount) then
error("LibAnimate: repeatCount must be 0 (infinite) or a positive integer", 2)
end
local state = {
definition = def,
keyframes = def.keyframes,
startTime = GetTime(),
duration = duration,
distance = opts.distance or def.defaultDistance or 0,
delay = delay,
repeatCount = repeatCount,
currentRepeat = 1,
onFinished = opts.onFinished,
anchorPoint = pt,
anchorRelativeTo = rel,
anchorRelativePoint = relPt,
anchorX = x or 0,
anchorY = y or 0,
originalScale = originalScale,
originalAlpha = originalAlpha,
hasScale = hasScale,
hasTranslate = hasTranslate,
resolvedEasings = resolvedEasings,
}
lib.activeAnimations[frame] = state
driverFrame:Show()
-- Apply first keyframe immediately so frames are placed at their
-- animation start positions right away. Without this, entrance
-- animations leave the frame visible at its original anchor until
-- the next OnUpdate tick, which causes overlapping frames when
-- multiple animations are started in quick succession.
local kf1 = def.keyframes[1]
ApplyToFrame(frame, state,
GetProperty(kf1, "translateX"),
GetProperty(kf1, "translateY"),
GetProperty(kf1, "scale"),
GetProperty(kf1, "alpha"))
return true
end
--- Stops the animation on a frame and restores it to its pre-animation state.
--- Only restores properties that the animation actually modified:
--- translate and scale are conditional, alpha is always restored.
--- If the frame has an active animation queue, the queue is also cleared.
--- Does nothing if the frame is not currently animating and has no queue.
--- The `onFinished` callback is NOT fired when an animation is stopped.
---@param frame Frame The frame to stop animating
function lib:Stop(frame)
-- Clear any active queue on this frame
lib.animationQueues[frame] = nil
local state = lib.activeAnimations[frame]
if not state then return end
-- Restore only properties that were animated
if state.hasTranslate then
frame:ClearAllPoints()
frame:SetPoint(state.anchorPoint, state.anchorRelativeTo, state.anchorRelativePoint,
state.anchorX, state.anchorY)
end
if state.hasScale then
frame:SetScale(state.originalScale)
end
frame:SetAlpha(state.originalAlpha)
lib.activeAnimations[frame] = nil
if not next(lib.activeAnimations) then
driverFrame:Hide()
end
end
--- Updates the base anchor offsets of an in-progress animation.
--- Use this when the frame's logical position changes during animation
--- (e.g. repositioning a notification while it slides in).
--- Does nothing if the frame is not currently animating.
---@param frame Frame The animated frame
---@param x number New base anchor X offset
---@param y number New base anchor Y offset
function lib:UpdateAnchor(frame, x, y)
local state = lib.activeAnimations[frame]
if state then
state.anchorX = x
state.anchorY = y
end
end
--- Returns whether a frame currently has an active animation.
---@param frame Frame The frame to check
---@return boolean isAnimating True if the frame is currently animating
function lib:IsAnimating(frame)
return lib.activeAnimations[frame] ~= nil
end
-------------------------------------------------------------------------------
-- Animation Queue
-------------------------------------------------------------------------------
--- Internal helper to start the next entry in an animation queue.
--- Retrieves the current queue entry, builds options, and calls Animate
--- with an internal onFinished that advances the queue.
--- Exposed as lib._startQueueEntry for internal use by SkipToEntry/RemoveQueueEntry.
---@param self LibAnimate
---@param frame Frame The frame being animated
local function StartQueueEntry(self, frame)
local queue = self.animationQueues[frame]
if not queue then return end
local entry = queue.entries[queue.index]
if not entry then
if queue.loop then
if #queue.entries == 0 then
self.animationQueues[frame] = nil
if queue.onFinished then queue.onFinished(frame) end
return
end
queue.index = 1
StartQueueEntry(self, frame)
return
end
-- Queue exhausted
local onFinished = queue.onFinished
self.animationQueues[frame] = nil
if onFinished then
local ok, err = pcall(onFinished, frame)
if not ok then
geterrorhandler()(err)
end
end
return
end
local opts = {
duration = entry.duration,
distance = entry.distance,
delay = entry.delay,
repeatCount = entry.repeatCount,
onFinished = function(f)
-- Fire per-step callback (pcall so queue always advances)
if entry.onFinished then
local ok, err = pcall(entry.onFinished, f)
if not ok then
geterrorhandler()(err)
end
end
-- Advance queue
if self.animationQueues[f] then
self.animationQueues[f].index =
self.animationQueues[f].index + 1
StartQueueEntry(self, f)
end
end,
}
-- Preserve slide state from current animation before Animate() destroys it.
-- When a queue transitions between entries, Animate() calls Stop() which
-- restores the frame to its pre-animation anchor and wipes all state.
-- Without this, a SlideAnchor call that started during a previous entry
-- would be lost, snapping the frame to a stale position.
local prevState = lib.activeAnimations[frame]
local savedSlide
if prevState and prevState.slideStartTime then
savedSlide = {
anchorX = prevState.anchorX,
anchorY = prevState.anchorY,
slideFromX = prevState.slideFromX,
slideFromY = prevState.slideFromY,
slideToX = prevState.slideToX,
slideToY = prevState.slideToY,
slideDuration = prevState.slideDuration,
slideStartTime = prevState.slideStartTime,
slideElapsedAtPause = prevState.slideElapsedAtPause,
}
end
-- Save/restore queue around Animate() since it clears queues
local savedQueue = self.animationQueues[frame]
self:Animate(frame, entry.name, opts)
self.animationQueues[frame] = savedQueue
-- Restore in-progress slide state onto the new animation state.
-- This includes anchorX/Y so the frame continues from its current
-- mid-slide position rather than snapping to the anchor that
-- Stop() restored during the Animate() call.
if savedSlide then
local newState = lib.activeAnimations[frame]
if newState then
newState.anchorX = savedSlide.anchorX
newState.anchorY = savedSlide.anchorY
newState.slideFromX = savedSlide.slideFromX
newState.slideFromY = savedSlide.slideFromY
newState.slideToX = savedSlide.slideToX
newState.slideToY = savedSlide.slideToY
newState.slideDuration = savedSlide.slideDuration
newState.slideStartTime = savedSlide.slideStartTime
newState.slideElapsedAtPause = savedSlide.slideElapsedAtPause
end
end
end
lib._startQueueEntry = StartQueueEntry
--- Queues a sequence of animations to play one after another on a frame.
--- Each entry can have its own duration, distance, delay, repeatCount,
--- and onFinished callback.
--- The sequence-level `onFinished` fires after the entire queue completes.
---
--- If any animation is already running on the frame, it is stopped and the
--- frame is restored before the queue begins.
---
--- All animation names are validated upfront; an error is thrown if any
--- entry references an unregistered animation.
---
--- The `opts.loop` flag causes the queue to restart from entry 1 after the
--- last entry finishes. While looping, `opts.onFinished` is NOT called.
--- A looping queue can be stopped with `ClearQueue()` or `Stop()`.
--- If all entries are removed from a looping queue (via `RemoveQueueEntry`),
--- the loop ends and `onFinished` is called.
---@param frame Frame The frame to animate
---@param entries QueueEntry[] Array of animation steps
---@param opts QueueOpts? Sequence-level options (onFinished, loop)
function lib:Queue(frame, entries, opts)
if not frame then
error("LibAnimate:Queue — frame must not be nil", 2)
end
if type(entries) ~= "table" or #entries == 0 then
error("LibAnimate:Queue — entries must be a non-empty table", 2)
end
if opts ~= nil and type(opts) ~= "table" then
error("LibAnimate:Queue — opts must be a table or nil", 2)
end
opts = opts or {}
if opts.loop ~= nil and type(opts.loop) ~= "boolean" then
error("LibAnimate:Queue — opts.loop must be a boolean", 2)
end
-- Validate all animation names upfront
for i, entry in ipairs(entries) do
if type(entry.name) ~= "string"
or not lib.animations[entry.name]
then
error(
"LibAnimate:Queue — invalid animation name '"
.. tostring(entry.name)
.. "' at entry " .. i,
2
)
end
end
-- Stop any current animation and clear existing queue
self:Stop(frame)
-- Initialize the queue
lib.animationQueues[frame] = {
entries = entries,
index = 1,
onFinished = opts.onFinished,
loop = opts.loop or false,
}
StartQueueEntry(self, frame)
end
--- Cancels the animation queue on a frame and stops the current animation.
--- The frame is restored to its pre-animation state. No callbacks are fired.
---@param frame Frame The frame to cancel the queue on
function lib:ClearQueue(frame)
lib.animationQueues[frame] = nil
self:Stop(frame)
end
--- Returns whether a frame has an active animation queue.
---@param frame Frame The frame to check
---@return boolean isQueued True if the frame has a pending queue
function lib:IsQueued(frame)
return lib.animationQueues[frame] ~= nil
end
-------------------------------------------------------------------------------
-- Pause / Resume / IsPaused
-------------------------------------------------------------------------------
--- Freezes the current animation mid-progress.
--- The OnUpdate loop skips paused frames entirely. Does nothing if the
--- frame has no active animation or is already paused.
---@param frame Frame The frame to pause
function lib:PauseQueue(frame)
local state = lib.activeAnimations[frame]
if not state or state.isPaused then return end
local now = GetTime()
state.elapsedAtPause = now - state.startTime
state.isPaused = true
-- Also freeze any active slide
if state.slideStartTime then
state.slideElapsedAtPause = now - state.slideStartTime
end
end
--- Resumes a paused animation from exactly where it left off.
--- Does nothing if the frame is not paused.
---@param frame Frame The frame to resume
function lib:ResumeQueue(frame)
local state = lib.activeAnimations[frame]
if not state or not state.isPaused then return end
local now = GetTime()
state.startTime = now - state.elapsedAtPause
state.isPaused = nil
state.elapsedAtPause = nil
-- Also resume any active slide
if state.slideStartTime and state.slideElapsedAtPause then
state.slideStartTime = now - state.slideElapsedAtPause
state.slideElapsedAtPause = nil
end
end
--- Returns whether a frame has an active animation that is paused.
---@param frame Frame The frame to check
---@return boolean isPaused True if the frame's animation is paused
function lib:IsPaused(frame)
local state = lib.activeAnimations[frame]
return state ~= nil and state.isPaused == true
end
-------------------------------------------------------------------------------
-- SlideAnchor
-------------------------------------------------------------------------------
--- Smoothly interpolates the internal anchor position over the given
--- duration without interrupting the current animation or queue. The
--- running animation continues calculating offsets relative to the