This commit is contained in:
Michael Freno
2025-11-18 14:15:51 -05:00
parent d86f7dbd5e
commit 5bb1162e06
5 changed files with 1233 additions and 42 deletions

View File

@@ -4,54 +4,19 @@
-- ErrorHandler dependency (injected via initializeErrorHandler)
local ErrorHandler = nil
--- Easing functions for animations
---@type table<string, EasingFunction>
local Easing = {
linear = function(t)
return t
end,
-- Easing module for easing functions
local Easing = require("modules.Easing")
---@class Keyframe
---@field at number Normalized time position (0-1)
---@field values table Property values at this keyframe
---@field easing string|EasingFunction? Easing to use between this and next keyframe
easeInQuad = function(t)
return t * t
end,
easeOutQuad = function(t)
return t * (2 - t)
end,
easeInOutQuad = function(t)
return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t
end,
easeInCubic = function(t)
return t * t * t
end,
easeOutCubic = function(t)
local t1 = t - 1
return t1 * t1 * t1 + 1
end,
easeInOutCubic = function(t)
return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
end,
easeInQuart = function(t)
return t * t * t * t
end,
easeOutQuart = function(t)
local t1 = t - 1
return 1 - t1 * t1 * t1 * t1
end,
easeInExpo = function(t)
return t == 0 and 0 or math.pow(2, 10 * (t - 1))
end,
easeOutExpo = function(t)
return t == 1 and 1 or 1 - math.pow(2, -10 * t)
end,
}
---@class AnimationProps
---@field duration number Duration in seconds
---@field start table Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, etc.)
---@field final table Final values (same properties as start)
---@field easing string? Easing function name (default: "linear")
---@field keyframes Keyframe[]? Array of keyframes for complex animations
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@field onStart function? Called when animation starts: (animation, element)
@@ -65,6 +30,7 @@ local Easing = {
---@field final table Final values
---@field elapsed number Elapsed time in seconds
---@field easing EasingFunction Easing function
---@field keyframes Keyframe[]? Array of keyframes for complex animations
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@field _cachedResult table Cached interpolation result
@@ -103,6 +69,7 @@ function Animation.new(props)
self.duration = props.duration
self.start = props.start
self.final = props.final
self.keyframes = props.keyframes
self.transform = props.transform
self.transition = props.transition
self.elapsed = 0
@@ -304,6 +271,98 @@ local function lerpTable(startTable, finalTable, easedT)
return result
end
--- Find the two keyframes surrounding the current progress
---@param progress number Current animation progress (0-1)
---@return Keyframe prevFrame The keyframe before current progress
---@return Keyframe nextFrame The keyframe after current progress
function Animation:findKeyframes(progress)
if not self.keyframes or #self.keyframes < 2 then
return nil, nil
end
-- Find surrounding keyframes
local prevFrame = self.keyframes[1]
local nextFrame = self.keyframes[#self.keyframes]
for i = 1, #self.keyframes - 1 do
if progress >= self.keyframes[i].at and progress <= self.keyframes[i + 1].at then
prevFrame = self.keyframes[i]
nextFrame = self.keyframes[i + 1]
break
end
end
return prevFrame, nextFrame
end
--- Interpolate between two keyframes
---@param prevFrame Keyframe Starting keyframe
---@param nextFrame Keyframe Ending keyframe
---@param easedT number Eased time (0-1) for interpolation
---@return table result Interpolated values
function Animation:lerpKeyframes(prevFrame, nextFrame, easedT)
local result = {}
-- Get all unique property keys
local keys = {}
for k in pairs(prevFrame.values) do keys[k] = true end
for k in pairs(nextFrame.values) do keys[k] = true end
-- Define properties that should be animated as numbers
local numericProperties = {
"width", "height", "opacity", "x", "y",
"gap", "imageOpacity", "scrollbarWidth",
"borderWidth", "fontSize", "lineHeight"
}
-- Define properties that should be animated as Colors
local colorProperties = {
"backgroundColor", "borderColor", "textColor",
"scrollbarColor", "scrollbarBackgroundColor", "imageTint"
}
-- Define properties that should be animated as tables
local tableProperties = {
"padding", "margin", "cornerRadius"
}
-- Create lookup sets for faster property type checking
local numericSet = {}
for _, prop in ipairs(numericProperties) do numericSet[prop] = true end
local colorSet = {}
for _, prop in ipairs(colorProperties) do colorSet[prop] = true end
local tableSet = {}
for _, prop in ipairs(tableProperties) do tableSet[prop] = true end
-- Interpolate each property
for key in pairs(keys) do
local startVal = prevFrame.values[key]
local finalVal = nextFrame.values[key]
if numericSet[key] and type(startVal) == "number" and type(finalVal) == "number" then
result[key] = lerpNumber(startVal, finalVal, easedT)
elseif colorSet[key] and self._Color then
if startVal ~= nil and finalVal ~= nil then
result[key] = lerpColor(startVal, finalVal, easedT, self._Color)
end
elseif tableSet[key] and type(startVal) == "table" and type(finalVal) == "table" then
result[key] = lerpTable(startVal, finalVal, easedT)
elseif type(startVal) == type(finalVal) then
-- For unknown types, try numeric interpolation if they're numbers
if type(startVal) == "number" then
result[key] = lerpNumber(startVal, finalVal, easedT)
else
-- Otherwise use the final value
result[key] = finalVal
end
end
end
return result
end
--- Calculate the current animated values between start and end states based on elapsed time
--- Use this to get the interpolated properties to apply to your element
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
@@ -315,6 +374,50 @@ function Animation:interpolate()
local t = math.min(self.elapsed / self.duration, 1)
-- Handle keyframe animations
if self.keyframes and #self.keyframes >= 2 then
local prevFrame, nextFrame = self:findKeyframes(t)
if prevFrame and nextFrame then
-- Calculate local progress between keyframes
local localProgress = 0
if nextFrame.at > prevFrame.at then
localProgress = (t - prevFrame.at) / (nextFrame.at - prevFrame.at)
end
-- Apply per-keyframe easing
local easingFn = Easing.linear
if prevFrame.easing then
if type(prevFrame.easing) == "string" then
easingFn = Easing[prevFrame.easing] or Easing.linear
elseif type(prevFrame.easing) == "function" then
easingFn = prevFrame.easing
end
end
local success, easedT = pcall(easingFn, localProgress)
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
easedT = localProgress
end
-- Interpolate between keyframes
local keyframeResult = self:lerpKeyframes(prevFrame, nextFrame, easedT)
-- Copy to cached result
local result = self._cachedResult
for k in pairs(result) do
result[k] = nil
end
for k, v in pairs(keyframeResult) do
result[k] = v
end
self._resultDirty = false
return result
end
end
-- Standard interpolation (non-keyframe)
-- Apply easing function with protection
local success, easedT = pcall(self.easing, t)
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
@@ -651,6 +754,67 @@ function Animation.scale(duration, fromScale, toScale, easing)
})
end
--- Create a keyframe-based animation with multiple waypoints and per-keyframe easing
--- Use this for complex multi-step animations like bounce-in effects or CSS-style @keyframes
---@param props {duration:number, keyframes:Keyframe[], onStart:function?, onUpdate:function?, onComplete:function?, onCancel:function?} Animation properties
---@return Animation animation The keyframe animation
function Animation.keyframes(props)
if not ErrorHandler then
ErrorHandler = require("modules.ErrorHandler")
end
-- Validate input
if type(props) ~= "table" then
ErrorHandler.warn("Animation", "Animation.keyframes() requires a table argument. Using default values.")
props = {duration = 1, keyframes = {}}
end
if type(props.duration) ~= "number" or props.duration <= 0 then
ErrorHandler.warn("Animation", "Keyframe animation duration must be a positive number. Using 1 second.")
props.duration = 1
end
if type(props.keyframes) ~= "table" or #props.keyframes < 2 then
ErrorHandler.warn("Animation", "Keyframe animation requires at least 2 keyframes. Using empty animation.")
props.keyframes = {
{at = 0, values = {}},
{at = 1, values = {}}
}
end
-- Sort keyframes by 'at' position
local sortedKeyframes = {}
for i, kf in ipairs(props.keyframes) do
if type(kf) == "table" and type(kf.at) == "number" and type(kf.values) == "table" then
table.insert(sortedKeyframes, kf)
end
end
table.sort(sortedKeyframes, function(a, b) return a.at < b.at end)
-- Ensure keyframes start at 0 and end at 1
if #sortedKeyframes > 0 then
if sortedKeyframes[1].at > 0 then
table.insert(sortedKeyframes, 1, {at = 0, values = sortedKeyframes[1].values})
end
if sortedKeyframes[#sortedKeyframes].at < 1 then
table.insert(sortedKeyframes, {at = 1, values = sortedKeyframes[#sortedKeyframes].values})
end
end
-- Create animation with keyframes
return Animation.new({
duration = props.duration,
start = {},
final = {},
keyframes = sortedKeyframes,
onStart = props.onStart,
onUpdate = props.onUpdate,
onComplete = props.onComplete,
onCancel = props.onCancel,
})
end
--- Initialize ErrorHandler dependency
---@param errorHandler table The ErrorHandler module
local function initializeErrorHandler(errorHandler)

361
modules/Easing.lua Normal file
View File

@@ -0,0 +1,361 @@
--- Easing functions for animations
--- Provides 30+ easing functions for smooth animation transitions
---
--- Easing function type
---@alias EasingFunction fun(t: number): number
---
---@class Easing
local Easing = {}
-- ============================================================================
-- Linear
-- ============================================================================
---@type EasingFunction
function Easing.linear(t)
return t
end
-- ============================================================================
-- Quadratic (Quad)
-- ============================================================================
---@type EasingFunction
function Easing.easeInQuad(t)
return t * t
end
---@type EasingFunction
function Easing.easeOutQuad(t)
return t * (2 - t)
end
---@type EasingFunction
function Easing.easeInOutQuad(t)
return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t
end
-- ============================================================================
-- Cubic
-- ============================================================================
---@type EasingFunction
function Easing.easeInCubic(t)
return t * t * t
end
---@type EasingFunction
function Easing.easeOutCubic(t)
local t1 = t - 1
return t1 * t1 * t1 + 1
end
---@type EasingFunction
function Easing.easeInOutCubic(t)
return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
end
-- ============================================================================
-- Quartic (Quart)
-- ============================================================================
---@type EasingFunction
function Easing.easeInQuart(t)
return t * t * t * t
end
---@type EasingFunction
function Easing.easeOutQuart(t)
local t1 = t - 1
return 1 - t1 * t1 * t1 * t1
end
---@type EasingFunction
function Easing.easeInOutQuart(t)
if t < 0.5 then
return 8 * t * t * t * t
else
local t1 = t - 1
return 1 - 8 * t1 * t1 * t1 * t1
end
end
-- ============================================================================
-- Quintic (Quint)
-- ============================================================================
---@type EasingFunction
function Easing.easeInQuint(t)
return t * t * t * t * t
end
---@type EasingFunction
function Easing.easeOutQuint(t)
local t1 = t - 1
return 1 + t1 * t1 * t1 * t1 * t1
end
---@type EasingFunction
function Easing.easeInOutQuint(t)
if t < 0.5 then
return 16 * t * t * t * t * t
else
local t1 = t - 1
return 1 + 16 * t1 * t1 * t1 * t1 * t1
end
end
-- ============================================================================
-- Exponential (Expo)
-- ============================================================================
---@type EasingFunction
function Easing.easeInExpo(t)
return t == 0 and 0 or math.pow(2, 10 * (t - 1))
end
---@type EasingFunction
function Easing.easeOutExpo(t)
return t == 1 and 1 or 1 - math.pow(2, -10 * t)
end
---@type EasingFunction
function Easing.easeInOutExpo(t)
if t == 0 then return 0 end
if t == 1 then return 1 end
if t < 0.5 then
return 0.5 * math.pow(2, 20 * t - 10)
else
return 1 - 0.5 * math.pow(2, -20 * t + 10)
end
end
-- ============================================================================
-- Sine
-- ============================================================================
---@type EasingFunction
function Easing.easeInSine(t)
return 1 - math.cos(t * math.pi / 2)
end
---@type EasingFunction
function Easing.easeOutSine(t)
return math.sin(t * math.pi / 2)
end
---@type EasingFunction
function Easing.easeInOutSine(t)
return -(math.cos(math.pi * t) - 1) / 2
end
-- ============================================================================
-- Circular (Circ)
-- ============================================================================
---@type EasingFunction
function Easing.easeInCirc(t)
return 1 - math.sqrt(1 - t * t)
end
---@type EasingFunction
function Easing.easeOutCirc(t)
local t1 = t - 1
return math.sqrt(1 - t1 * t1)
end
---@type EasingFunction
function Easing.easeInOutCirc(t)
if t < 0.5 then
return (1 - math.sqrt(1 - 4 * t * t)) / 2
else
local t1 = -2 * t + 2
return (math.sqrt(1 - t1 * t1) + 1) / 2
end
end
-- ============================================================================
-- Back (Overshoot)
-- ============================================================================
---@type EasingFunction
function Easing.easeInBack(t)
local c1 = 1.70158
local c3 = c1 + 1
return c3 * t * t * t - c1 * t * t
end
---@type EasingFunction
function Easing.easeOutBack(t)
local c1 = 1.70158
local c3 = c1 + 1
local t1 = t - 1
return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1
end
---@type EasingFunction
function Easing.easeInOutBack(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
else
local t1 = 2 * t - 2
return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2
end
end
-- ============================================================================
-- Elastic (Spring)
-- ============================================================================
---@type EasingFunction
function Easing.easeInElastic(t)
if t == 0 then return 0 end
if t == 1 then return 1 end
local c4 = (2 * math.pi) / 3
return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4)
end
---@type EasingFunction
function Easing.easeOutElastic(t)
if t == 0 then return 0 end
if t == 1 then return 1 end
local c4 = (2 * math.pi) / 3
return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1
end
---@type EasingFunction
function Easing.easeInOutElastic(t)
if t == 0 then return 0 end
if t == 1 then return 1 end
local c5 = (2 * math.pi) / 4.5
if t < 0.5 then
return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2
else
return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1
end
end
-- ============================================================================
-- Bounce
-- ============================================================================
---@type EasingFunction
function Easing.easeOutBounce(t)
local n1 = 7.5625
local d1 = 2.75
if t < 1 / d1 then
return n1 * t * t
elseif t < 2 / d1 then
local t1 = t - 1.5 / d1
return n1 * t1 * t1 + 0.75
elseif t < 2.5 / d1 then
local t1 = t - 2.25 / d1
return n1 * t1 * t1 + 0.9375
else
local t1 = t - 2.625 / d1
return n1 * t1 * t1 + 0.984375
end
end
---@type EasingFunction
function Easing.easeInBounce(t)
return 1 - Easing.easeOutBounce(1 - t)
end
---@type EasingFunction
function Easing.easeInOutBounce(t)
if t < 0.5 then
return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2
else
return (1 + Easing.easeOutBounce(2 * t - 1)) / 2
end
end
-- ============================================================================
-- Configurable Easing Factories
-- ============================================================================
--- Create a custom back easing function with configurable overshoot
---@param overshoot number? Overshoot amount (default: 1.70158)
---@return EasingFunction
function Easing.back(overshoot)
overshoot = overshoot or 1.70158
local c3 = overshoot + 1
return function(t)
return c3 * t * t * t - overshoot * t * t
end
end
--- Create a custom elastic easing function
---@param amplitude number? Amplitude (default: 1)
---@param period number? Period (default: 0.3)
---@return EasingFunction
function Easing.elastic(amplitude, period)
amplitude = amplitude or 1
period = period or 0.3
return function(t)
if t == 0 then return 0 end
if t == 1 then return 1 end
local s = period / 4
local a = amplitude
if a < 1 then
a = 1
s = period / 4
else
s = period / (2 * math.pi) * math.asin(1 / a)
end
return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1
end
end
--- Get list of all available easing function names
---@return string[] names Array of easing function names
function Easing.list()
return {
-- Linear
"linear",
-- Quad
"easeInQuad", "easeOutQuad", "easeInOutQuad",
-- Cubic
"easeInCubic", "easeOutCubic", "easeInOutCubic",
-- Quart
"easeInQuart", "easeOutQuart", "easeInOutQuart",
-- Quint
"easeInQuint", "easeOutQuint", "easeInOutQuint",
-- Expo
"easeInExpo", "easeOutExpo", "easeInOutExpo",
-- Sine
"easeInSine", "easeOutSine", "easeInOutSine",
-- Circ
"easeInCirc", "easeOutCirc", "easeInOutCirc",
-- Back
"easeInBack", "easeOutBack", "easeInOutBack",
-- Elastic
"easeInElastic", "easeOutElastic", "easeInOutElastic",
-- Bounce
"easeInBounce", "easeOutBounce", "easeInOutBounce",
}
end
--- Get an easing function by name
---@param name string Easing function name
---@return EasingFunction? easing The easing function, or nil if not found
function Easing.get(name)
return Easing[name]
end
return Easing