more work on Animation

This commit is contained in:
Michael Freno
2025-11-18 12:17:12 -05:00
parent 6f3fa0e473
commit 96150e5cf4
10 changed files with 810 additions and 79 deletions

View File

@@ -194,7 +194,7 @@ jobs:
## Documentation
📚 [View Documentation](https://github.com/${{ github.repository }}/tree/main/docs)
📚 [View Documentation](https://mikefreno.github.io/FlexLove/)
## What's Included

View File

@@ -19,7 +19,7 @@
## Architecture
- **Immediate mode**: Elements recreated each frame, layout triggered by `endFrame()``layoutChildren()` called on top-level elements
- **Retained mode**: Elements persist, must manually update properties (default)
- **Dependencies**: Pass via `deps` table parameter in constructors (e.g., `{utils, ErrorHandler, Units}`)
- **Dependencies**: Pass via `deps` table parameter in constructors (e.g., `{utils, ErrorHandler, Units}`). Do not use require outside of `FlexLove.lua`.
- **Layout flow**: `Element.new()``layoutChildren()` on construction → `resize()` on viewport change → `layoutChildren()` again
- **CSS positioning**: `top/right/bottom/left` applied via `LayoutEngine:applyPositioningOffsets()` for absolute/relative containers

View File

@@ -31,6 +31,8 @@ local Element = req("Element")
-- externals
---@type Animation
local Animation = req("Animation")
---@type AnimationGroup
local AnimationGroup = req("AnimationGroup")
---@type Color
local Color = req("Color")
---@type Theme
@@ -95,6 +97,12 @@ Color.initializeErrorHandler(ErrorHandler)
-- Initialize ErrorHandler for utils
utils.initializeErrorHandler(ErrorHandler)
-- Initialize ErrorHandler for Animation module
Animation.initializeErrorHandler(ErrorHandler)
-- Initialize ErrorHandler for AnimationGroup module
AnimationGroup.initializeErrorHandler(ErrorHandler)
-- Add version and metadata
flexlove._VERSION = "0.2.3"
flexlove._DESCRIPTION = "0I Library for LÖVE Framework based on flexbox"
@@ -1085,6 +1093,7 @@ function flexlove.getStateStats()
end
flexlove.Animation = Animation
flexlove.AnimationGroup = AnimationGroup
flexlove.Color = Color
flexlove.Theme = Theme
flexlove.enums = enums

View File

@@ -19,49 +19,73 @@ function lv.draw()
local container = FlexLove.new({
width = "100vw",
height = "100vh",
positioning = "flex",
flexDirection = "vertical",
padding = { top = 20, right = 20, bottom = 20, left = 20 },
gap = 20,
backgroundColor = Color.new(0.95, 0.95, 0.95, 1),
overflow = "scroll",
padding = { top = 20, right = 20, bottom = 20, left = 20 },
})
-- Title
local title = FlexLove.new({
FlexLove.new({
parent = container,
text = "FlexLove Image Showcase",
textSize = "xxl",
textColor = Color.new(0.2, 0.2, 0.2, 1),
textAlign = "center",
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 0, right = 0, bottom = 20, left = 0 },
})
-- Section 1: Object-Fit Modes
local fitSection = FlexLove.new({
parent = container,
width = "100%",
flexDirection = "vertical",
gap = 10,
})
local fitTitle = FlexLove.new({
FlexLove.new({
parent = fitSection,
text = "Object-Fit Modes",
textSize = "lg",
textColor = Color.new(0.3, 0.3, 0.3, 1),
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 5, right = 0, bottom = 5, left = 0 },
})
local fitRow = FlexLove.new({
parent = fitSection,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
gap = 15,
justifyContent = "space-between",
alignItems = "flex-start",
padding = { top = 30 },
})
local fitModes = { "fill", "contain", "cover", "scale-down", "none" }
for _, mode in ipairs(fitModes) do
local fitSizes = {
{ width = 200, height = 140, imgWidth = 180, imgHeight = 100 },
{ width = 160, height = 120, imgWidth = 140, imgHeight = 80 },
{ width = 220, height = 160, imgWidth = 200, imgHeight = 120 },
{ width = 180, height = 130, imgWidth = 160, imgHeight = 90 },
{ width = 190, height = 150, imgWidth = 170, imgHeight = 110 },
}
for i, mode in ipairs(fitModes) do
local size = fitSizes[i]
local fitBox = FlexLove.new({
parent = fitRow,
width = 180,
height = 120,
width = size.width,
height = size.height,
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = Color.new(1, 1, 1, 1),
@@ -69,51 +93,74 @@ function lv.draw()
padding = { top = 10, right = 10, bottom = 10, left = 10 },
})
local fitImage = FlexLove.new({
FlexLove.new({
parent = fitBox,
width = 160,
height = 80,
width = size.imgWidth,
height = size.imgHeight,
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
cornerRadius = 4,
imagePath = "sample.jpg",
objectFit = mode,
})
local fitLabel = FlexLove.new({
FlexLove.new({
parent = fitBox,
text = mode,
textSize = "sm",
textColor = Color.new(0.4, 0.4, 0.4, 1),
textAlign = "center",
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 3, right = 0, bottom = 3, left = 0 },
})
end
-- Section 2: Object-Position
local posSection = FlexLove.new({
parent = container,
width = "100%",
flexDirection = "vertical",
gap = 10,
})
local posTitle = FlexLove.new({
FlexLove.new({
parent = posSection,
text = "Object-Position",
textSize = "lg",
textColor = Color.new(0.3, 0.3, 0.3, 1),
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 5, right = 0, bottom = 5, left = 0 },
})
local posRow = FlexLove.new({
parent = posSection,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
gap = 15,
justifyContent = "space-between",
alignItems = "flex-start",
padding = { top = 30 },
})
local positions = { "top left", "center center", "bottom right", "50% 20%", "left center" }
for _, pos in ipairs(positions) do
local posSizes = {
{ width = 170, height = 130, imgWidth = 150, imgHeight = 90 },
{ width = 210, height = 150, imgWidth = 190, imgHeight = 110 },
{ width = 180, height = 140, imgWidth = 160, imgHeight = 100 },
{ width = 195, height = 135, imgWidth = 175, imgHeight = 95 },
{ width = 185, height = 145, imgWidth = 165, imgHeight = 105 },
}
for i, pos in ipairs(positions) do
local size = posSizes[i]
local posBox = FlexLove.new({
parent = posRow,
width = 180,
height = 120,
width = size.width,
height = size.height,
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = Color.new(1, 1, 1, 1),
@@ -121,53 +168,74 @@ function lv.draw()
padding = { top = 10, right = 10, bottom = 10, left = 10 },
})
local posImage = FlexLove.new({
FlexLove.new({
parent = posBox,
width = 160,
height = 80,
width = size.imgWidth,
height = size.imgHeight,
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
cornerRadius = 4,
imagePath = "sample.jpg",
objectFit = "none",
objectPosition = pos,
})
local posLabel = FlexLove.new({
FlexLove.new({
parent = posBox,
text = pos,
textSize = "xs",
textColor = Color.new(0.4, 0.4, 0.4, 1),
textAlign = "center",
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 3, right = 0, bottom = 3, left = 0 },
})
end
-- Section 3: Image Tiling/Repeat
local tileSection = FlexLove.new({
parent = container,
width = "100%",
flexDirection = "vertical",
gap = 10,
})
local tileTitle = FlexLove.new({
FlexLove.new({
parent = tileSection,
text = "Image Tiling (Repeat Modes)",
textSize = "lg",
textColor = Color.new(0.3, 0.3, 0.3, 1),
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 5, right = 0, bottom = 5, left = 0 },
})
local tileRow = FlexLove.new({
parent = tileSection,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
gap = 20,
justifyContent = "space-between",
alignItems = "flex-start",
padding = { top = 30 },
})
local repeatModes = { "no-repeat", "repeat", "repeat-x", "repeat-y" }
for _, mode in ipairs(repeatModes) do
local tileSizes = {
{ width = 260, height = 140, imgWidth = 240, imgHeight = 100 },
{ width = 240, height = 130, imgWidth = 220, imgHeight = 90 },
{ width = 280, height = 150, imgWidth = 260, imgHeight = 110 },
{ width = 250, height = 135, imgWidth = 230, imgHeight = 95 },
}
for i, mode in ipairs(repeatModes) do
local size = tileSizes[i]
local tileBox = FlexLove.new({
parent = tileRow,
width = 240,
height = 120,
width = size.width,
height = size.height,
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = Color.new(1, 1, 1, 1),
@@ -175,44 +243,56 @@ function lv.draw()
padding = { top = 10, right = 10, bottom = 10, left = 10 },
})
local tileImage = FlexLove.new({
FlexLove.new({
parent = tileBox,
width = 220,
height = 80,
width = size.imgWidth,
height = size.imgHeight,
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
cornerRadius = 4,
-- imagePath = "assets/pattern.png", -- Uncomment if you have a pattern image
imagePath = "sample.jpg",
imageRepeat = mode,
})
local tileLabel = FlexLove.new({
FlexLove.new({
parent = tileBox,
text = mode,
textSize = "sm",
textColor = Color.new(0.4, 0.4, 0.4, 1),
textAlign = "center",
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 3, right = 0, bottom = 3, left = 0 },
})
end
-- Section 4: Image Tinting and Opacity
local tintSection = FlexLove.new({
parent = container,
width = "100%",
flexDirection = "vertical",
gap = 10,
})
local tintTitle = FlexLove.new({
FlexLove.new({
parent = tintSection,
text = "Image Tinting & Opacity",
textSize = "lg",
textColor = Color.new(0.3, 0.3, 0.3, 1),
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 5, right = 0, bottom = 5, left = 0 },
})
local tintRow = FlexLove.new({
parent = tintSection,
width = "100%",
positioning = "flex",
flexDirection = "horizontal",
gap = 10,
justifyContent = "space-around",
gap = 15,
justifyContent = "space-between",
alignItems = "flex-start",
padding = { top = 30 },
})
local tints = {
@@ -223,11 +303,21 @@ function lv.draw()
{ name = "Green + 70%", color = Color.new(0.5, 1, 0.5, 1), opacity = 0.7 },
}
for _, tint in ipairs(tints) do
local tintSizes = {
{ width = 185, height = 135, imgWidth = 165, imgHeight = 95 },
{ width = 200, height = 145, imgWidth = 180, imgHeight = 105 },
{ width = 175, height = 130, imgWidth = 155, imgHeight = 90 },
{ width = 195, height = 140, imgWidth = 175, imgHeight = 100 },
{ width = 190, height = 150, imgWidth = 170, imgHeight = 110 },
}
for i, tint in ipairs(tints) do
local size = tintSizes[i]
local tintBox = FlexLove.new({
parent = tintRow,
width = 180,
height = 120,
width = size.width,
height = size.height,
positioning = "flex",
flexDirection = "vertical",
gap = 5,
backgroundColor = Color.new(1, 1, 1, 1),
@@ -235,34 +325,40 @@ function lv.draw()
padding = { top = 10, right = 10, bottom = 10, left = 10 },
})
local tintImage = FlexLove.new({
FlexLove.new({
parent = tintBox,
width = 160,
height = 80,
width = size.imgWidth,
height = size.imgHeight,
backgroundColor = Color.new(0.9, 0.9, 0.9, 1),
cornerRadius = 4,
imagePath = "sample.jpg",
imageTint = tint.color,
imageOpacity = tint.opacity,
})
local tintLabel = FlexLove.new({
FlexLove.new({
parent = tintBox,
text = tint.name,
textSize = "xs",
textColor = Color.new(0.4, 0.4, 0.4, 1),
textAlign = "center",
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 3, right = 0, bottom = 3, left = 0 },
})
end
-- Footer note
local note = FlexLove.new({
FlexLove.new({
parent = container,
text = "Note: Uncomment imagePath properties in code to see actual images",
text = "Image showcase demonstrating various FlexLove image properties",
textSize = "xs",
textColor = Color.new(0.5, 0.5, 0.5, 1),
textAlign = "center",
padding = { top = 10, right = 0, bottom = 0, left = 0 },
textWrap = "word",
width = "100%",
z = 1000,
padding = { top = 10, right = 0, bottom = 10, left = 0 },
})
end

View File

@@ -1,6 +1,9 @@
--- Easing function type
---@alias EasingFunction fun(t: number): number
-- ErrorHandler dependency (injected via initializeErrorHandler)
local ErrorHandler = nil
--- Easing functions for animations
---@type table<string, EasingFunction>
local Easing = {
@@ -76,19 +79,23 @@ Animation.__index = Animation
function Animation.new(props)
-- Validate input
if type(props) ~= "table" then
error("[FlexLove.Animation] Animation.new() requires a table argument")
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
error("[FlexLove.Animation] Animation duration must be a positive number")
ErrorHandler.warn("Animation", "Animation duration must be a positive number. Using 1 second.")
props.duration = 1
end
if type(props.start) ~= "table" then
error("[FlexLove.Animation] Animation start must be a table")
ErrorHandler.warn("Animation", "Animation start must be a table. Using empty table.")
props.start = {}
end
if type(props.final) ~= "table" then
error("[FlexLove.Animation] Animation final must be a table")
ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.")
props.final = {}
end
local self = setmetatable({}, Animation)
@@ -144,6 +151,14 @@ function Animation:update(dt, element)
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
@@ -180,8 +195,32 @@ function Animation:update(dt, element)
self.elapsed = self.elapsed + dt
if self.elapsed >= self.duration then
self.elapsed = self.duration
self._state = "completed"
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)
@@ -355,8 +394,13 @@ end
---Apply this animation to an element
---@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
error("[FlexLove.Animation] Cannot apply animation to nil or non-table element")
ErrorHandler.warn("Animation", "Cannot apply animation to nil or non-table element. Animation not applied.")
return
end
element.animation = self
end
@@ -464,6 +508,71 @@ function Animation:getProgress()
return math.min(self.elapsed / self.duration, 1)
end
---Chain another animation after this one completes
---@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
---Add delay before animation starts
---@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
---Repeat animation multiple times
---@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
---Enable yoyo mode (animation reverses direction on each repeat)
---@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
--- Create a simple fade animation
---@param duration number Duration in seconds
---@param fromOpacity number Starting opacity (0-1)
@@ -520,4 +629,13 @@ function Animation.scale(duration, fromScale, toScale, easing)
})
end
--- Initialize ErrorHandler dependency
---@param errorHandler table The ErrorHandler module
local function initializeErrorHandler(errorHandler)
ErrorHandler = errorHandler
end
-- Export ErrorHandler initializer
Animation.initializeErrorHandler = initializeErrorHandler
return Animation

327
modules/AnimationGroup.lua Normal file
View File

@@ -0,0 +1,327 @@
--- AnimationGroup module for running multiple animations together
---@class AnimationGroup
local AnimationGroup = {}
AnimationGroup.__index = AnimationGroup
-- ErrorHandler dependency (injected via initializeErrorHandler)
local ErrorHandler = nil
---@class AnimationGroupProps
---@field animations table Array of Animation instances
---@field mode string? "parallel", "sequence", or "stagger" (default: "parallel")
---@field stagger number? Stagger delay in seconds (for stagger mode, default: 0.1)
---@field onComplete function? Called when all animations complete: (group)
---@field onStart function? Called when group starts: (group)
--- Create a new animation group
---@param props AnimationGroupProps
---@return AnimationGroup group
function AnimationGroup.new(props)
if type(props) ~= "table" then
ErrorHandler.warn("AnimationGroup", "AnimationGroup.new() requires a table argument. Using default values.")
props = {animations = {}}
end
if type(props.animations) ~= "table" or #props.animations == 0 then
ErrorHandler.warn("AnimationGroup", "AnimationGroup requires at least one animation. Creating empty group.")
props.animations = {}
end
local self = setmetatable({}, AnimationGroup)
self.animations = props.animations
self.mode = props.mode or "parallel"
self.stagger = props.stagger or 0.1
self.onComplete = props.onComplete
self.onStart = props.onStart
-- Validate mode
if self.mode ~= "parallel" and self.mode ~= "sequence" and self.mode ~= "stagger" then
ErrorHandler.warn("AnimationGroup", string.format("Invalid mode: %s. Using 'parallel'.", tostring(self.mode)))
self.mode = "parallel"
end
-- Internal state
self._currentIndex = 1
self._staggerElapsed = 0
self._startedAnimations = {}
self._hasStarted = false
self._paused = false
self._state = "ready" -- "ready", "playing", "completed", "cancelled"
return self
end
--- Update all animations in parallel
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateParallel(dt, element)
local allFinished = true
for i, anim in ipairs(self.animations) do
-- Check if animation has isCompleted method or check state
local isCompleted = false
if type(anim.getState) == "function" then
isCompleted = anim:getState() == "completed"
elseif anim._state then
isCompleted = anim._state == "completed"
end
if not isCompleted then
local finished = anim:update(dt, element)
if not finished then
allFinished = false
end
end
end
return allFinished
end
--- Update animations in sequence (one after another)
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateSequence(dt, element)
if self._currentIndex > #self.animations then
return true
end
local currentAnim = self.animations[self._currentIndex]
local finished = currentAnim:update(dt, element)
if finished then
self._currentIndex = self._currentIndex + 1
if self._currentIndex > #self.animations then
return true
end
end
return false
end
--- Update animations with stagger delay
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if all animations complete
function AnimationGroup:_updateStagger(dt, element)
self._staggerElapsed = self._staggerElapsed + dt
-- Start animations based on stagger timing
for i, anim in ipairs(self.animations) do
local startTime = (i - 1) * self.stagger
if self._staggerElapsed >= startTime and not self._startedAnimations[i] then
self._startedAnimations[i] = true
end
end
-- Update started animations
local allFinished = true
for i, anim in ipairs(self.animations) do
if self._startedAnimations[i] then
local isCompleted = false
if type(anim.getState) == "function" then
isCompleted = anim:getState() == "completed"
elseif anim._state then
isCompleted = anim._state == "completed"
end
if not isCompleted then
local finished = anim:update(dt, element)
if not finished then
allFinished = false
end
end
else
allFinished = false
end
end
return allFinished
end
--- Update the animation group
---@param dt number Delta time
---@param element table? Optional element reference for callbacks
---@return boolean finished True if group is complete
function AnimationGroup:update(dt, element)
-- Sanitize dt
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
dt = 0
end
if self._paused or self._state == "completed" or self._state == "cancelled" then
return self._state == "completed"
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)
if not success then
print(string.format("[AnimationGroup] onStart error: %s", tostring(err)))
end
end
end
local finished = false
if self.mode == "parallel" then
finished = self:_updateParallel(dt, element)
elseif self.mode == "sequence" then
finished = self:_updateSequence(dt, element)
elseif self.mode == "stagger" then
finished = self:_updateStagger(dt, element)
end
if finished then
self._state = "completed"
if self.onComplete and type(self.onComplete) == "function" then
local success, err = pcall(self.onComplete, self)
if not success then
print(string.format("[AnimationGroup] onComplete error: %s", tostring(err)))
end
end
end
return finished
end
--- Pause all animations in the group
function AnimationGroup:pause()
self._paused = true
for _, anim in ipairs(self.animations) do
if type(anim.pause) == "function" then
anim:pause()
end
end
end
--- Resume all animations in the group
function AnimationGroup:resume()
self._paused = false
for _, anim in ipairs(self.animations) do
if type(anim.resume) == "function" then
anim:resume()
end
end
end
--- Check if group is paused
---@return boolean paused
function AnimationGroup:isPaused()
return self._paused
end
--- Reverse all animations in the group
function AnimationGroup:reverse()
for _, anim in ipairs(self.animations) do
if type(anim.reverse) == "function" then
anim:reverse()
end
end
end
--- Set speed for all animations in the group
---@param speed number Speed multiplier
function AnimationGroup:setSpeed(speed)
for _, anim in ipairs(self.animations) do
if type(anim.setSpeed) == "function" then
anim:setSpeed(speed)
end
end
end
--- Cancel all animations in the group
---@param element table? Optional element reference for callbacks
function AnimationGroup:cancel(element)
if self._state ~= "cancelled" and self._state ~= "completed" then
self._state = "cancelled"
for _, anim in ipairs(self.animations) do
if type(anim.cancel) == "function" then
anim:cancel(element)
end
end
end
end
--- Reset the animation group to initial state
function AnimationGroup:reset()
self._currentIndex = 1
self._staggerElapsed = 0
self._startedAnimations = {}
self._hasStarted = false
self._paused = false
self._state = "ready"
for _, anim in ipairs(self.animations) do
if type(anim.reset) == "function" then
anim:reset()
end
end
end
--- Get the current state of the group
---@return string state "ready", "playing", "completed", "cancelled"
function AnimationGroup:getState()
return self._state
end
--- Get the overall progress of the group (0-1)
---@return number progress
function AnimationGroup:getProgress()
if #self.animations == 0 then
return 1
end
if self.mode == "sequence" then
-- For sequence, progress is based on current animation index + current animation progress
local completedAnims = self._currentIndex - 1
local currentProgress = 0
if self._currentIndex <= #self.animations then
local currentAnim = self.animations[self._currentIndex]
if type(currentAnim.getProgress) == "function" then
currentProgress = currentAnim:getProgress()
end
end
return (completedAnims + currentProgress) / #self.animations
else
-- For parallel and stagger, average progress of all animations
local totalProgress = 0
for _, anim in ipairs(self.animations) do
if type(anim.getProgress) == "function" then
totalProgress = totalProgress + anim:getProgress()
else
totalProgress = totalProgress + 1
end
end
return totalProgress / #self.animations
end
end
--- Apply this animation group to an element
---@param element Element The element to apply animations to
function AnimationGroup:apply(element)
if not element or type(element) ~= "table" then
ErrorHandler.warn("AnimationGroup", "Cannot apply animation group to nil or non-table element. Group not applied.")
return
end
element.animationGroup = self
end
--- Initialize ErrorHandler dependency
---@param errorHandler table The ErrorHandler module
local function initializeErrorHandler(errorHandler)
ErrorHandler = errorHandler
end
-- Export ErrorHandler initializer
AnimationGroup.initializeErrorHandler = initializeErrorHandler
return AnimationGroup

View File

@@ -2127,7 +2127,19 @@ function Element:update(dt)
local finished = self.animation:update(dt, self)
if finished then
-- Animation:update() already called onComplete callback
self.animation = nil -- remove finished animation
-- Check for chained animation
if self.animation._next then
self.animation = self.animation._next
elseif self.animation._nextFactory and type(self.animation._nextFactory) == "function" then
local success, nextAnim = pcall(self.animation._nextFactory, self)
if success and nextAnim then
self.animation = nextAnim
else
self.animation = nil
end
else
self.animation = nil
end
else
-- Apply animation interpolation during update
local anim = self.animation:interpolate()
@@ -2968,4 +2980,121 @@ function Element:setTransformOrigin(originX, originY)
self.transform.originY = originY
end
--- Set transition configuration for a property
---@param property string Property name or "all" for all properties
---@param config table Transition config {duration, easing, delay, onComplete}
function Element:setTransition(property, config)
if not self.transitions then
self.transitions = {}
end
if type(config) ~= "table" then
self._deps.ErrorHandler.warn("Element", "setTransition() requires a config table. Using default config.")
config = {}
end
-- Validate config
if config.duration and (type(config.duration) ~= "number" or config.duration < 0) then
self._deps.ErrorHandler.warn("Element", "transition duration must be a non-negative number. Using 0.3 seconds.")
config.duration = 0.3
end
self.transitions[property] = {
duration = config.duration or 0.3,
easing = config.easing or "easeOutQuad",
delay = config.delay or 0,
onComplete = config.onComplete
}
end
--- Set transition configuration for multiple properties
---@param groupName string Name for this transition group
---@param config table Transition config {duration, easing, delay, onComplete}
---@param properties table Array of property names
function Element:setTransitionGroup(groupName, config, properties)
if type(properties) ~= "table" then
self._deps.ErrorHandler.warn("Element", "setTransitionGroup() requires a properties array. No transitions set.")
return
end
for _, prop in ipairs(properties) do
self:setTransition(prop, config)
end
end
--- Remove transition configuration for a property
---@param property string Property name or "all" to remove all
function Element:removeTransition(property)
if not self.transitions then
return
end
if property == "all" then
self.transitions = {}
else
self.transitions[property] = nil
end
end
--- Set property with automatic transition
---@param property string Property name
---@param value any New value
function Element:setProperty(property, value)
-- Check if transitions are enabled for this property
local shouldTransition = false
local transitionConfig = nil
if self.transitions then
transitionConfig = self.transitions[property] or self.transitions["all"]
shouldTransition = transitionConfig ~= nil
end
-- Don't transition if value is the same
if self[property] == value then
return
end
if shouldTransition and transitionConfig then
-- Get current value
local currentValue = self[property]
-- Only transition if we have a valid current value
if currentValue ~= nil then
-- Create animation for the property change
local Animation = require("modules.Animation")
local anim = Animation.new({
duration = transitionConfig.duration,
start = { [property] = currentValue },
final = { [property] = value },
easing = transitionConfig.easing,
onComplete = transitionConfig.onComplete
})
-- Set Color module reference if needed
if self._deps and self._deps.Color then
anim:setColorModule(self._deps.Color)
end
-- Set Transform module reference if needed
if self._deps and self._deps.Transform then
anim:setTransformModule(self._deps.Transform)
end
-- Apply delay if configured
if transitionConfig.delay and transitionConfig.delay > 0 then
anim:delay(transitionConfig.delay)
end
-- Apply animation
anim:apply(self)
else
-- No current value, set directly
self[property] = value
end
else
-- No transition, set directly
self[property] = value
end
end
return Element

View File

@@ -190,10 +190,45 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
if hasCornerRadius then
-- Use stencil to clip image to rounded corners
love.graphics.stencil(function()
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
local success, err = pcall(function()
love.graphics.stencil(function()
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
end)
if not success then
-- Lazy-load ErrorHandler if needed
if not ErrorHandler then
ErrorHandler = require("modules.ErrorHandler")
end
-- Check if it's a stencil buffer error
if err and err:match("stencil") then
ErrorHandler.warn(
"Renderer",
"IMG_001",
"Cannot apply corner radius to image: stencil buffer not available",
{
imagePath = self.imagePath or "unknown",
cornerRadius = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight
),
error = tostring(err),
},
"Ensure the active canvas has stencil=true enabled, or remove cornerRadius from images"
)
-- Continue without corner radius
hasCornerRadius = false
else
-- Re-throw if it's a different error
error(err, 2)
end
end
end
-- Draw the image based on repeat mode

View File

@@ -3,6 +3,17 @@ require("testing.loveStub")
local Animation = require("modules.Animation")
local Color = require("modules.Color")
local Transform = require("modules.Transform")
local ErrorHandler = require("modules.ErrorHandler")
local ErrorCodes = require("modules.ErrorCodes")
-- Initialize ErrorHandler
ErrorHandler.init({ ErrorCodes = ErrorCodes })
Animation.initializeErrorHandler(ErrorHandler)
Color.initializeErrorHandler(ErrorHandler)
-- Make Color module available to Animation
Animation.setColorModule(Color)
TestAnimationProperties = {}

View File

@@ -2,6 +2,12 @@ local luaunit = require("testing.luaunit")
require("testing.loveStub")
local Animation = require("modules.Animation")
local ErrorHandler = require("modules.ErrorHandler")
local ErrorCodes = require("modules.ErrorCodes")
-- Initialize ErrorHandler for Animation module
ErrorHandler.init({ ErrorCodes = ErrorCodes })
Animation.initializeErrorHandler(ErrorHandler)
TestAnimation = {}
@@ -22,25 +28,25 @@ function TestAnimation:testNewWithNilDuration()
end
function TestAnimation:testNewWithNegativeDuration()
-- Should throw an error for invalid duration
luaunit.assertErrorMsgContains("duration must be a positive number", function()
Animation.new({
duration = -1,
start = { opacity = 0 },
final = { opacity = 1 },
})
end)
-- Should warn and use default duration (1 second) for invalid duration
local anim = Animation.new({
duration = -1,
start = { opacity = 0 },
final = { opacity = 1 },
})
luaunit.assertNotNil(anim)
luaunit.assertEquals(anim.duration, 1) -- Default value
end
function TestAnimation:testNewWithZeroDuration()
-- Should throw an error for invalid duration
luaunit.assertErrorMsgContains("duration must be a positive number", function()
Animation.new({
duration = 0,
start = { opacity = 0 },
final = { opacity = 1 },
})
end)
-- Should warn and use default duration (1 second) for invalid duration
local anim = Animation.new({
duration = 0,
start = { opacity = 0 },
final = { opacity = 1 },
})
luaunit.assertNotNil(anim)
luaunit.assertEquals(anim.duration, 1) -- Default value
end
function TestAnimation:testNewWithInvalidEasing()