local ErrorHandler = nil local Easing = nil local Color = nil ---@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 ---@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) ---@field onUpdate function? Called each frame: (animation, element, progress) ---@field onComplete function? Called when animation completes: (animation, element) ---@field onCancel function? Called when animation is cancelled: (animation, element) ---@class Animation ---@field duration number Duration in seconds ---@field start table Starting values ---@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 ---@field _resultDirty boolean Whether cached result needs recalculation ---@field _Color table? Reference to Color module (for lerp) local Animation = {} Animation.__index = Animation --- Build smooth, timed transitions between visual states to create polished, professional UIs --- Use this to animate position, size, opacity, colors, and other properties with customizable easing ---@param props AnimationProps Animation properties ---@return Animation animation The new animation instance function Animation.new(props) -- Validate input if type(props) ~= "table" then ErrorHandler.warn("Animation", "Animation.new() requires a table argument. Using default values.") props = { duration = 1, start = {}, final = {} } end if type(props.duration) ~= "number" or props.duration <= 0 then ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.") props.duration = 1 end if type(props.start) ~= "table" then ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.") props.start = {} end if type(props.final) ~= "table" then ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.") props.final = {} end local self = setmetatable({}, Animation) 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 -- Lifecycle callbacks self.onStart = props.onStart self.onUpdate = props.onUpdate self.onComplete = props.onComplete self.onCancel = props.onCancel self._hasStarted = false -- Control state self._paused = false self._reversed = false self._speed = 1.0 self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled" -- Validate and set easing function local easingName = props.easing or "linear" if type(easingName) == "string" then self.easing = Easing[easingName] or Easing.linear elseif type(easingName) == "function" then self.easing = easingName else self.easing = Easing.linear end -- Pre-allocate result table to avoid GC pressure self._cachedResult = {} self._resultDirty = true return self end --- Advance the animation timeline and calculate interpolated values for the current frame --- Call this each frame to progress the animation; returns true when complete for cleanup ---@param dt number Delta time in seconds ---@param element table? Optional element reference for callbacks ---@return boolean completed True if animation is complete function Animation:update(dt, element) -- Sanitize dt if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then dt = 0 end -- Don't update if paused if self._paused then return false end -- Handle delay if self._delay and self._delayElapsed then if self._delayElapsed < self._delay then self._delayElapsed = self._delayElapsed + dt return false end end -- Call onStart on first update if not self._hasStarted then self._hasStarted = true self._state = "playing" if self.onStart and type(self.onStart) == "function" then local success, err = pcall(self.onStart, self, element) if not success then -- Log error but don't crash print(string.format("[Animation] onStart error: %s", tostring(err))) end end end -- Apply speed multiplier dt = dt * self._speed -- Update elapsed time (reversed if needed) if self._reversed then self.elapsed = self.elapsed - dt if self.elapsed <= 0 then self.elapsed = 0 self._state = "completed" self._resultDirty = true -- Call onComplete callback if self.onComplete and type(self.onComplete) == "function" then local success, err = pcall(self.onComplete, self, element) if not success then print(string.format("[Animation] onComplete error: %s", tostring(err))) end end return true end else self.elapsed = self.elapsed + dt if self.elapsed >= self.duration then self.elapsed = self.duration self._resultDirty = true -- Handle repeat and yoyo if self._repeatCount then self._repeatCurrent = (self._repeatCurrent or 0) + 1 if self._repeatCount == 0 or self._repeatCurrent < self._repeatCount then -- Continue repeating if self._yoyo then -- Reverse direction for yoyo self._reversed = not self._reversed if self._reversed then self.elapsed = self.duration else self.elapsed = 0 end else -- Reset to beginning self.elapsed = 0 end return false end end -- Animation truly completed self._state = "completed" -- Call onComplete callback if self.onComplete and type(self.onComplete) == "function" then local success, err = pcall(self.onComplete, self, element) if not success then print(string.format("[Animation] onComplete error: %s", tostring(err))) end end return true end end self._resultDirty = true -- Call onUpdate callback if self.onUpdate and type(self.onUpdate) == "function" then local progress = self.elapsed / self.duration local success, err = pcall(self.onUpdate, self, element, progress) if not success then print(string.format("[Animation] onUpdate error: %s", tostring(err))) end end return false end --- Helper function to interpolate numeric values ---@param startValue number Starting value ---@param finalValue number Final value ---@param easedT number Eased time (0-1) ---@return number interpolated Interpolated value local function lerpNumber(startValue, finalValue, easedT) return startValue * (1 - easedT) + finalValue * easedT end --- Helper function to interpolate Color values ---@param startColor any Starting color (Color instance or parseable color) ---@param finalColor any Final color (Color instance or parseable color) ---@param easedT number Eased time (0-1) ---@param ColorModule table Color module reference ---@return any interpolated Interpolated Color instance local function lerpColor(startColor, finalColor, easedT, ColorModule) -- Use provided ColorModule or fall back to module-level Color or static _ColorModule local CM = ColorModule or Color or Animation._ColorModule if not CM or not CM.parse or not CM.lerp then if ErrorHandler then ErrorHandler.warn("Animation", "Color module not properly initialized. Cannot interpolate colors.") end return startColor -- Return start color as fallback end -- Parse colors if needed local colorA = CM.parse(startColor) local colorB = CM.parse(finalColor) return CM.lerp(colorA, colorB, easedT) end --- Helper function to interpolate table values (padding, margin, cornerRadius) ---@param startTable table Starting table ---@param finalTable table Final table ---@param easedT number Eased time (0-1) ---@return table interpolated Interpolated table local function lerpTable(startTable, finalTable, easedT) local result = {} -- Iterate through all keys in both tables local keys = {} for k in pairs(startTable) do keys[k] = true end for k in pairs(finalTable) do keys[k] = true end for key in pairs(keys) do local startVal = startTable[key] local finalVal = finalTable[key] if type(startVal) == "number" and type(finalVal) == "number" then result[key] = lerpNumber(startVal, finalVal, easedT) elseif startVal ~= nil then result[key] = startVal else result[key] = finalVal end end 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 or Animation._ColorModule) then if startVal ~= nil and finalVal ~= nil then result[key] = lerpColor(startVal, finalVal, easedT, self._Color or Animation._ColorModule) 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?, ...} function Animation:interpolate() -- Return cached result if not dirty (avoids recalculation) if not self._resultDirty then return self._cachedResult end local t = math.min(self.elapsed / self.duration, 1) -- Handle keyframe animations if self.keyframes and type(self.keyframes) == "table" 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 easedT = t -- Fallback to linear if easing fails end local result = self._cachedResult -- Reuse existing table -- Clear previous results for k in pairs(result) do result[k] = nil 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", } -- Interpolate numeric properties for _, prop in ipairs(numericProperties) do local startVal = self.start[prop] local finalVal = self.final[prop] if type(startVal) == "number" and type(finalVal) == "number" then result[prop] = lerpNumber(startVal, finalVal, easedT) end end -- Interpolate color properties (if Color module is available) local ColorModule = self._Color or Animation._ColorModule if ColorModule then for _, prop in ipairs(colorProperties) do local startVal = self.start[prop] local finalVal = self.final[prop] if startVal ~= nil and finalVal ~= nil then result[prop] = lerpColor(startVal, finalVal, easedT, ColorModule) end end end -- Interpolate table properties for _, prop in ipairs(tableProperties) do local startVal = self.start[prop] local finalVal = self.final[prop] if type(startVal) == "table" and type(finalVal) == "table" then result[prop] = lerpTable(startVal, finalVal, easedT) end end -- Interpolate transform property (if Transform module is available) if self._Transform and self.start.transform and self.final.transform then result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT) end -- Copy transform properties (legacy support) if self.transform and type(self.transform) == "table" then for key, value in pairs(self.transform) do result[key] = value end end self._resultDirty = false return result end --- Attach this animation to an element so it automatically updates and applies changes --- Use this for hands-off animation that integrates with FlexLove's rendering system ---@param element Element The element to apply animation to function Animation:apply(element) if not ErrorHandler then ErrorHandler = require("modules.ErrorHandler") end if not element or type(element) ~= "table" then ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.") return end element.animation = self end --- Set Color module reference for color interpolation ---@param ColorModule table Color module function Animation:setColorModule(ColorModule) self._Color = ColorModule end --- Set Transform module reference for transform interpolation ---@param TransformModule table Transform module function Animation:setTransformModule(TransformModule) self._Transform = TransformModule end --- Temporarily halt the animation without losing progress --- Use this to freeze animations during pause menus or cutscenes function Animation:pause() if self._state == "playing" or self._state == "pending" then self._paused = true self._state = "paused" end end --- Continue a paused animation from where it left off --- Use this to unpause animations when returning from pause menus function Animation:resume() if self._state == "paused" then self._paused = false self._state = "playing" end end --- Query pause state to conditionally handle animation logic --- Use this to sync UI behavior with animation state ---@return boolean paused function Animation:isPaused() return self._paused end --- Flip the animation to play backwards, creating smooth transitions in both directions --- Use this for hover effects that reverse on mouse-out or toggleable UI elements function Animation:reverse() self._reversed = not self._reversed end --- Determine current playback direction for conditional animation logic --- Use this to track which direction the animation is playing ---@return boolean reversed function Animation:isReversed() return self._reversed end --- Control animation tempo for slow-motion or fast-forward effects --- Use this for bullet-time, game speed multipliers, or debugging ---@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed) function Animation:setSpeed(speed) if type(speed) == "number" and speed > 0 then self._speed = speed end end --- Check current playback speed for debugging or UI display --- Use this to show animation speed in dev tools ---@return number speed Current speed multiplier function Animation:getSpeed() return self._speed end --- Jump to any point in the animation timeline for previewing or state restoration --- Use this to skip ahead, rewind, or restore saved animation states ---@param time number Time in seconds (clamped to 0-duration) function Animation:seek(time) if type(time) == "number" then self.elapsed = math.max(0, math.min(time, self.duration)) self._resultDirty = true end end --- Query animation lifecycle state for conditional logic and debugging --- Use this to determine if cleanup is needed or to prevent duplicate animations ---@return string state Current state: "pending", "playing", "paused", "completed", "cancelled" function Animation:getState() return self._state end --- Stop the animation immediately without completing, triggering the onCancel callback --- Use this to abort animations when UI elements are removed or user cancels an action ---@param element table? Optional element reference for callback function Animation:cancel(element) if self._state ~= "cancelled" and self._state ~= "completed" then self._state = "cancelled" if self.onCancel and type(self.onCancel) == "function" then local success, err = pcall(self.onCancel, self, element) if not success then print(string.format("[Animation] onCancel error: %s", tostring(err))) end end end end --- Return the animation to the beginning for replay --- Use this to reuse animation instances without recreating them function Animation:reset() self.elapsed = 0 self._hasStarted = false self._paused = false self._state = "pending" self._resultDirty = true end --- Get normalized animation progress for progress bars or synchronized effects --- Use this to drive secondary animations or display completion percentage ---@return number progress Progress from 0 to 1 function Animation:getProgress() return math.min(self.elapsed / self.duration, 1) end --- Create sequential animation flows that play one after another --- Use this to build complex multi-step animations like slide-in-then-fade ---@param nextAnimation Animation|function Animation instance or factory function that returns an animation ---@return Animation nextAnimation The chained animation (for further chaining) function Animation:chain(nextAnimation) if not ErrorHandler then ErrorHandler = require("modules.ErrorHandler") end if type(nextAnimation) == "function" then self._nextFactory = nextAnimation return self elseif type(nextAnimation) == "table" then self._next = nextAnimation return nextAnimation else ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.") return self end end --- Introduce a wait period before animation begins for staggered effects --- Use this to create cascading animations or timed sequences ---@param seconds number Delay duration in seconds ---@return Animation self For chaining function Animation:delay(seconds) if not ErrorHandler then ErrorHandler = require("modules.ErrorHandler") end if type(seconds) ~= "number" or seconds < 0 then ErrorHandler.warn("Animation", "delay() requires a non-negative number. Using 0.") seconds = 0 end self._delay = seconds self._delayElapsed = 0 return self end --- Loop the animation for pulsing effects, loading indicators, or continuous motion --- Use this for idle animations and attention-grabbing elements ---@param count number Number of times to repeat (0 = infinite loop) ---@return Animation self For chaining function Animation:repeatCount(count) if not ErrorHandler then ErrorHandler = require("modules.ErrorHandler") end if type(count) ~= "number" or count < 0 then ErrorHandler.warn("Animation", "repeatCount() requires a non-negative number. Using 0.") count = 0 end self._repeatCount = count self._repeatCurrent = 0 return self end --- Make repeating animations play forwards then backwards for smooth oscillation --- Use this for breathing effects, pulsing highlights, or pendulum motions ---@param enabled boolean? Enable yoyo mode (default: true) ---@return Animation self For chaining function Animation:yoyo(enabled) if enabled == nil then enabled = true end self._yoyo = enabled return self end --- Quickly create fade in/out effects without manually specifying start/end states --- Use this convenience method for common opacity transitions in tooltips, notifications, and overlays ---@param duration number Duration in seconds ---@param fromOpacity number Starting opacity (0-1) ---@param toOpacity number Ending opacity (0-1) ---@param easing string? Easing function name (default: "linear") ---@return Animation animation The fade animation function Animation.fade(duration, fromOpacity, toOpacity, easing) -- Sanitize inputs if type(duration) ~= "number" or duration <= 0 then duration = 1 end if type(fromOpacity) ~= "number" then fromOpacity = 1 end if type(toOpacity) ~= "number" then toOpacity = 0 end return Animation.new({ duration = duration, start = { opacity = fromOpacity }, final = { opacity = toOpacity }, easing = easing, transform = {}, transition = {}, }) end --- Quickly create grow/shrink effects without manually specifying dimensions --- Use this convenience method for bounce effects, pop-ups, and attention animations ---@param duration number Duration in seconds ---@param fromScale {width:number,height:number} Starting scale ---@param toScale {width:number,height:number} Ending scale ---@param easing string? Easing function name (default: "linear") ---@return Animation animation The scale animation function Animation.scale(duration, fromScale, toScale, easing) -- Sanitize inputs if type(duration) ~= "number" or duration <= 0 then duration = 1 end if type(fromScale) ~= "table" then fromScale = { width = 1, height = 1 } end if type(toScale) ~= "table" then toScale = { width = 1, height = 1 } end return Animation.new({ duration = duration, start = { width = fromScale.width or 0, height = fromScale.height or 0 }, final = { width = toScale.width or 0, height = toScale.height or 0 }, easing = easing, transform = {}, transition = {}, }) 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 dependencies ---@param deps table Dependencies: { ErrorHandler = ErrorHandler, Easing = Easing, Color = Color? } function Animation.init(deps) if type(deps) == "table" then ErrorHandler = deps.ErrorHandler Easing = deps.Easing if deps.Color then Color = deps.Color Animation._ColorModule = deps.Color end end end -- Static method for Color module injection (for per-instance Color override) function Animation.setColorModule(ColorModule) Animation._ColorModule = ColorModule end return Animation