From 96150e5cf422f58b260926d21208fef6ef70a6af Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 18 Nov 2025 12:17:12 -0500 Subject: [PATCH] more work on Animation --- .github/workflows/release.yml | 2 +- AGENTS.md | 2 +- FlexLove.lua | 9 + examples/image_showcase.lua | 196 ++++++++--- modules/Animation.lua | 130 ++++++- modules/AnimationGroup.lua | 327 ++++++++++++++++++ modules/Element.lua | 131 ++++++- modules/Renderer.lua | 43 ++- .../__tests__/animation_properties_test.lua | 11 + testing/__tests__/animation_test.lua | 38 +- 10 files changed, 810 insertions(+), 79 deletions(-) create mode 100644 modules/AnimationGroup.lua diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef707f6..e525eab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 592e36f..0918d7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/FlexLove.lua b/FlexLove.lua index e0eed88..771c31d 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -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 diff --git a/examples/image_showcase.lua b/examples/image_showcase.lua index dbb7d37..2a7dc19 100644 --- a/examples/image_showcase.lua +++ b/examples/image_showcase.lua @@ -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 diff --git a/modules/Animation.lua b/modules/Animation.lua index 3dc2952..79af2a6 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -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 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 diff --git a/modules/AnimationGroup.lua b/modules/AnimationGroup.lua new file mode 100644 index 0000000..85d7922 --- /dev/null +++ b/modules/AnimationGroup.lua @@ -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 diff --git a/modules/Element.lua b/modules/Element.lua index db6f152..35a5de7 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -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 diff --git a/modules/Renderer.lua b/modules/Renderer.lua index dc2d5ac..b433d16 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -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 diff --git a/testing/__tests__/animation_properties_test.lua b/testing/__tests__/animation_properties_test.lua index b9869e6..c7ed9e8 100644 --- a/testing/__tests__/animation_properties_test.lua +++ b/testing/__tests__/animation_properties_test.lua @@ -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 = {} diff --git a/testing/__tests__/animation_test.lua b/testing/__tests__/animation_test.lua index c4ce95f..6c095bc 100644 --- a/testing/__tests__/animation_test.lua +++ b/testing/__tests__/animation_test.lua @@ -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()