From b24af1717979a35d8520659ae60d7e9a2bd73b3d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 19 Nov 2025 12:14:58 -0500 Subject: [PATCH] starting refactor for sanity --- FlexLove.lua | 125 +-- modules/Animation.lua | 961 ++++++++++++++++-- modules/AnimationGroup.lua | 338 ------ modules/Color.lua | 158 ++- modules/Easing.lua | 361 ------- modules/Element.lua | 32 +- modules/ErrorCodes.lua | 473 --------- modules/ErrorHandler.lua | 808 +++++++++++---- modules/Transform.lua | 154 --- testing/__tests__/color_validation_test.lua | 4 + testing/__tests__/easing_test.lua | 109 +- testing/__tests__/event_handler_test.lua | 20 +- testing/__tests__/flexlove_test.lua | 32 +- testing/__tests__/font_cache_test.lua | 61 +- testing/__tests__/keyframe_animation_test.lua | 202 ++-- testing/__tests__/touch_events_test.lua | 124 ++- testing/runAll.lua | 10 +- 17 files changed, 1927 insertions(+), 2045 deletions(-) delete mode 100644 modules/AnimationGroup.lua delete mode 100644 modules/Easing.lua delete mode 100644 modules/ErrorCodes.lua delete mode 100644 modules/Transform.lua diff --git a/FlexLove.lua b/FlexLove.lua index 483664f..b0c8cf1 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -10,8 +10,6 @@ local Units = req("Units") local Context = req("Context") ---@type StateManager local StateManager = req("StateManager") -local ErrorCodes = req("ErrorCodes") -local ErrorHandler = req("ErrorHandler") local Performance = req("Performance") local ImageRenderer = req("ImageRenderer") local ImageScaler = req("ImageScaler") @@ -26,79 +24,23 @@ local LayoutEngine = req("LayoutEngine") local Renderer = req("Renderer") local EventHandler = req("EventHandler") local ScrollManager = req("ScrollManager") +local ImageDataReader = req("ImageDataReader") +---@type ErrorHandler +local ErrorHandler = req("ErrorHandler") ---@type Element local Element = req("Element") -- externals ---@type Animation local Animation = req("Animation") ----@type AnimationGroup -local AnimationGroup = req("AnimationGroup") ----@type Easing -local Easing = req("Easing") ---@type Color local Color = req("Color") ---@type Theme local Theme = req("Theme") local enums = utils.enums -Element.defaultDependencies = { - Context = Context, - Theme = Theme, - Color = Color, - Units = Units, - Blur = Blur, - ImageRenderer = ImageRenderer, - ImageScaler = ImageScaler, - NinePatch = NinePatch, - RoundedRect = RoundedRect, - ImageCache = ImageCache, - utils = utils, - Grid = Grid, - InputEvent = InputEvent, - GestureRecognizer = GestureRecognizer, - StateManager = StateManager, - TextEditor = TextEditor, - LayoutEngine = LayoutEngine, - Renderer = Renderer, - EventHandler = EventHandler, - ScrollManager = ScrollManager, - ErrorHandler = ErrorHandler, -} - ---@class FlexLove local flexlove = Context - --- Initialize ErrorHandler with ErrorCodes dependency -ErrorHandler.init({ ErrorCodes = ErrorCodes }) - --- Initialize modules that use ErrorHandler via DI -local errorHandlerDeps = { ErrorHandler = ErrorHandler } -if ImageRenderer.init then - ImageRenderer.init(errorHandlerDeps) -end -if ImageScaler then - local ImageScaler = req("ImageScaler") - if ImageScaler.init then - ImageScaler.init(errorHandlerDeps) - end -end -if NinePatch.init then - NinePatch.init(errorHandlerDeps) -end -local ImageDataReader = req("ImageDataReader") -if ImageDataReader.init then - ImageDataReader.init(errorHandlerDeps) -end - --- Initialize modules with dependencies -Units.init({ Context = Context, ErrorHandler = ErrorHandler }) -Color.init({ ErrorHandler = ErrorHandler }) -utils.init({ ErrorHandler = ErrorHandler }) -Animation.init({ ErrorHandler = ErrorHandler, Easing = Easing, Color = Color }) -AnimationGroup.init({ ErrorHandler = ErrorHandler }) - --- Add version and metadata flexlove._VERSION = "0.3.0" flexlove._DESCRIPTION = "UI Library for LÖVE Framework based on flexbox" flexlove._URL = "https://github.com/mikefreno/FlexLove" @@ -148,16 +90,52 @@ flexlove._deferredCallbacks = {} function flexlove.init(config) config = config or {} - if config.errorLogFile then - ErrorHandler.setLogTarget("file") - ErrorHandler.setLogFile(config.errorLogFile) - elseif config.enableErrorLogging == true then - -- Use default log file if logging enabled but no path specified - ErrorHandler.setLogTarget("file") - ErrorHandler.setLogFile("flexlove-errors.log") - end + flexlove._ErrorHandler = ErrorHandler.init({ + includeStackTrace = config.includeStackTrace, + logLevel = config.reportingLogLevel, + logTarget = config.errorLogTarget, + logFile = config.errorLogFile, + maxLogSize = config.errorLogMaxSize, + maxLogFiles = config.maxErrorLogFiles, + enableRotation = config.errorLogRotateEnabled, + }) + + ImageRenderer.init({ ErrorHandler = flexlove._ErrorHandler }) + + ImageScaler.init({ ErrorHandler = flexlove._ErrorHandler }) + + NinePatch.init({ ErrorHandler = flexlove._ErrorHandler }) + ImageDataReader.init({ ErrorHandler = flexlove._ErrorHandler }) + + Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler }) + Color.init({ ErrorHandler = flexlove._ErrorHandler }) + utils.init({ ErrorHandler = flexlove._ErrorHandler }) + Animation.init({ ErrorHandler = flexlove._ErrorHandler, Color = Color }) + + flexlove._defaultDependencies = { + Context = Context, + Theme = Theme, + Color = Color, + Units = Units, + Blur = Blur, + ImageRenderer = ImageRenderer, + ImageScaler = ImageScaler, + NinePatch = NinePatch, + RoundedRect = RoundedRect, + ImageCache = ImageCache, + utils = utils, + Grid = Grid, + InputEvent = InputEvent, + GestureRecognizer = GestureRecognizer, + StateManager = StateManager, + TextEditor = TextEditor, + LayoutEngine = LayoutEngine, + Renderer = Renderer, + EventHandler = EventHandler, + ScrollManager = ScrollManager, + ErrorHandler = flexlove._ErrorHandler, + } - -- Configure performance monitoring (default: true) local enablePerfMonitoring = config.performanceMonitoring if enablePerfMonitoring == nil then enablePerfMonitoring = true @@ -974,7 +952,7 @@ function flexlove.new(props) -- If not in immediate mode, use standard Element.new if not flexlove._immediateMode then - return Element.new(props, Element.defaultDependencies) + return Element.new(props, flexlove._defaultDependencies) end -- Auto-begin frame if not manually started (convenience feature) @@ -999,8 +977,7 @@ function flexlove.new(props) props._scrollX = state._scrollX or 0 props._scrollY = state._scrollY or 0 - -- Create the element - local element = Element.new(props, Element.defaultDependencies) + local element = Element.new(props, flexlove._defaultDependencies) -- Bind persistent state to element (ImmediateModeState) -- Restore event handler state @@ -1114,8 +1091,6 @@ function flexlove.getStateStats() end flexlove.Animation = Animation -flexlove.AnimationGroup = AnimationGroup -flexlove.Easing = Easing flexlove.Color = Color flexlove.Theme = Theme flexlove.enums = enums diff --git a/modules/Animation.lua b/modules/Animation.lua index b895224..40df83d 100644 --- a/modules/Animation.lua +++ b/modules/Animation.lua @@ -1,6 +1,512 @@ -local ErrorHandler = nil -local Easing = nil -local Color = nil +-- ============================================================================ +-- EASING FUNCTIONS +-- ============================================================================ + +--- Easing function type +---@alias EasingFunction fun(t: number): number + +local Easing = {} + +-- Linear +---@type EasingFunction +function Easing.linear(t) + return t +end + +-- Quadratic (Quad) +---@type EasingFunction +function Easing.easeInQuad(t) + return t * t +end + +---@type EasingFunction +function Easing.easeOutQuad(t) + return t * (2 - t) +end + +---@type EasingFunction +function Easing.easeInOutQuad(t) + return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t +end + +-- Cubic +---@type EasingFunction +function Easing.easeInCubic(t) + return t * t * t +end + +---@type EasingFunction +function Easing.easeOutCubic(t) + local t1 = t - 1 + return t1 * t1 * t1 + 1 +end + +---@type EasingFunction +function Easing.easeInOutCubic(t) + return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 +end + +-- Quartic (Quart) +---@type EasingFunction +function Easing.easeInQuart(t) + return t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuart(t) + local t1 = t - 1 + return 1 - t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuart(t) + if t < 0.5 then + return 8 * t * t * t * t + else + local t1 = t - 1 + return 1 - 8 * t1 * t1 * t1 * t1 + end +end + +-- Quintic (Quint) +---@type EasingFunction +function Easing.easeInQuint(t) + return t * t * t * t * t +end + +---@type EasingFunction +function Easing.easeOutQuint(t) + local t1 = t - 1 + return 1 + t1 * t1 * t1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutQuint(t) + if t < 0.5 then + return 16 * t * t * t * t * t + else + local t1 = t - 1 + return 1 + 16 * t1 * t1 * t1 * t1 * t1 + end +end + +-- Exponential (Expo) +---@type EasingFunction +function Easing.easeInExpo(t) + return t == 0 and 0 or math.pow(2, 10 * (t - 1)) +end + +---@type EasingFunction +function Easing.easeOutExpo(t) + return t == 1 and 1 or 1 - math.pow(2, -10 * t) +end + +---@type EasingFunction +function Easing.easeInOutExpo(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + if t < 0.5 then + return 0.5 * math.pow(2, 20 * t - 10) + else + return 1 - 0.5 * math.pow(2, -20 * t + 10) + end +end + +-- Sine +---@type EasingFunction +function Easing.easeInSine(t) + return 1 - math.cos(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeOutSine(t) + return math.sin(t * math.pi / 2) +end + +---@type EasingFunction +function Easing.easeInOutSine(t) + return -(math.cos(math.pi * t) - 1) / 2 +end + +-- Circular (Circ) +---@type EasingFunction +function Easing.easeInCirc(t) + return 1 - math.sqrt(1 - t * t) +end + +---@type EasingFunction +function Easing.easeOutCirc(t) + local t1 = t - 1 + return math.sqrt(1 - t1 * t1) +end + +---@type EasingFunction +function Easing.easeInOutCirc(t) + if t < 0.5 then + return (1 - math.sqrt(1 - 4 * t * t)) / 2 + else + local t1 = -2 * t + 2 + return (math.sqrt(1 - t1 * t1) + 1) / 2 + end +end + +-- Back (Overshoot) +---@type EasingFunction +function Easing.easeInBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + return c3 * t * t * t - c1 * t * t +end + +---@type EasingFunction +function Easing.easeOutBack(t) + local c1 = 1.70158 + local c3 = c1 + 1 + local t1 = t - 1 + return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 +end + +---@type EasingFunction +function Easing.easeInOutBack(t) + local c1 = 1.70158 + local c2 = c1 * 1.525 + + if t < 0.5 then + return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 + else + local t1 = 2 * t - 2 + return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 + end +end + +-- Elastic (Spring) +---@type EasingFunction +function Easing.easeInElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c4 = (2 * math.pi) / 3 + return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) +end + +---@type EasingFunction +function Easing.easeOutElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c4 = (2 * math.pi) / 3 + return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 +end + +---@type EasingFunction +function Easing.easeInOutElastic(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local c5 = (2 * math.pi) / 4.5 + + if t < 0.5 then + return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 + else + return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 + end +end + +-- Bounce +---@type EasingFunction +function Easing.easeOutBounce(t) + local n1 = 7.5625 + local d1 = 2.75 + + if t < 1 / d1 then + return n1 * t * t + elseif t < 2 / d1 then + local t1 = t - 1.5 / d1 + return n1 * t1 * t1 + 0.75 + elseif t < 2.5 / d1 then + local t1 = t - 2.25 / d1 + return n1 * t1 * t1 + 0.9375 + else + local t1 = t - 2.625 / d1 + return n1 * t1 * t1 + 0.984375 + end +end + +---@type EasingFunction +function Easing.easeInBounce(t) + return 1 - Easing.easeOutBounce(1 - t) +end + +---@type EasingFunction +function Easing.easeInOutBounce(t) + if t < 0.5 then + return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 + else + return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 + end +end + +-- Configurable Easing Factories +--- Create a custom back easing function with configurable overshoot +---@param overshoot number? Overshoot amount (default: 1.70158) +---@return EasingFunction +function Easing.back(overshoot) + overshoot = overshoot or 1.70158 + local c3 = overshoot + 1 + + return function(t) + return c3 * t * t * t - overshoot * t * t + end +end + +--- Create a custom elastic easing function +---@param amplitude number? Amplitude (default: 1) +---@param period number? Period (default: 0.3) +---@return EasingFunction +function Easing.elastic(amplitude, period) + amplitude = amplitude or 1 + period = period or 0.3 + + return function(t) + if t == 0 then + return 0 + end + if t == 1 then + return 1 + end + + local s = period / 4 + local a = amplitude + + if a < 1 then + a = 1 + s = period / 4 + else + s = period / (2 * math.pi) * math.asin(1 / a) + end + + return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 + end +end + +--- Get list of all available easing function names +---@return string[] names Array of easing function names +function Easing.list() + return { + "linear", + "easeInQuad", + "easeOutQuad", + "easeInOutQuad", + "easeInCubic", + "easeOutCubic", + "easeInOutCubic", + "easeInQuart", + "easeOutQuart", + "easeInOutQuart", + "easeInQuint", + "easeOutQuint", + "easeInOutQuint", + "easeInExpo", + "easeOutExpo", + "easeInOutExpo", + "easeInSine", + "easeOutSine", + "easeInOutSine", + "easeInCirc", + "easeOutCirc", + "easeInOutCirc", + "easeInBack", + "easeOutBack", + "easeInOutBack", + "easeInElastic", + "easeOutElastic", + "easeInOutElastic", + "easeInBounce", + "easeOutBounce", + "easeInOutBounce", + } +end + +--- Get an easing function by name +---@param name string Easing function name +---@return EasingFunction? easing The easing function, or nil if not found +function Easing.get(name) + return Easing[name] +end + +-- ============================================================================ +-- TRANSFORM +-- ============================================================================ + +---@class Transform +---@field rotate number? Rotation in radians (default: 0) +---@field scaleX number? X-axis scale (default: 1) +---@field scaleY number? Y-axis scale (default: 1) +---@field translateX number? X translation in pixels (default: 0) +---@field translateY number? Y translation in pixels (default: 0) +---@field skewX number? X-axis skew in radians (default: 0) +---@field skewY number? Y-axis skew in radians (default: 0) +---@field originX number? Transform origin X (0-1, default: 0.5) +---@field originY number? Transform origin Y (0-1, default: 0.5) +local Transform = {} +Transform.__index = Transform + +--- Create a new transform instance +---@param props TransformProps? +---@return Transform transform +function Transform.new(props) + props = props or {} + + local self = setmetatable({}, Transform) + + self.rotate = props.rotate or 0 + self.scaleX = props.scaleX or 1 + self.scaleY = props.scaleY or 1 + self.translateX = props.translateX or 0 + self.translateY = props.translateY or 0 + self.skewX = props.skewX or 0 + self.skewY = props.skewY or 0 + self.originX = props.originX or 0.5 + self.originY = props.originY or 0.5 + + return self +end + +--- Apply transform to LÖVE graphics context +---@param transform Transform Transform instance +---@param x number Element x position +---@param y number Element y position +---@param width number Element width +---@param height number Element height +function Transform.apply(transform, x, y, width, height) + if not transform then + return + end + + -- Calculate transform origin + local ox = x + width * transform.originX + local oy = y + height * transform.originY + + -- Apply transform in correct order: translate → rotate → scale → skew + love.graphics.push() + love.graphics.translate(ox, oy) + + if transform.rotate ~= 0 then + love.graphics.rotate(transform.rotate) + end + + if transform.scaleX ~= 1 or transform.scaleY ~= 1 then + love.graphics.scale(transform.scaleX, transform.scaleY) + end + + if transform.skewX ~= 0 or transform.skewY ~= 0 then + love.graphics.shear(transform.skewX, transform.skewY) + end + + love.graphics.translate(-ox, -oy) + love.graphics.translate(transform.translateX, transform.translateY) +end + +--- Remove transform from LÖVE graphics context +function Transform.unapply() + love.graphics.pop() +end + +--- Interpolate between two transforms +---@param from Transform Starting transform +---@param to Transform Ending transform +---@param t number Interpolation factor (0-1) +---@return Transform interpolated +function Transform.lerp(from, to, t) + -- Sanitize inputs + if type(from) ~= "table" then + from = Transform.new() + end + if type(to) ~= "table" then + to = Transform.new() + end + if type(t) ~= "number" or t ~= t then + t = 0 + elseif t == math.huge then + t = 1 + elseif t == -math.huge then + t = 0 + else + t = math.max(0, math.min(1, t)) + end + + return Transform.new({ + rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, + scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, + scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, + translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, + translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, + skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, + skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, + originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, + originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, + }) +end + +--- Check if transform is identity (no transformation) +---@param transform Transform +---@return boolean isIdentity +function Transform.isIdentity(transform) + if not transform then + return true + end + + return transform.rotate == 0 + and transform.scaleX == 1 + and transform.scaleY == 1 + and transform.translateX == 0 + and transform.translateY == 0 + and transform.skewX == 0 + and transform.skewY == 0 +end + +--- Clone a transform +---@param transform Transform +---@return Transform clone +function Transform.clone(transform) + if not transform then + return Transform.new() + end + + return Transform.new({ + rotate = transform.rotate, + scaleX = transform.scaleX, + scaleY = transform.scaleY, + translateX = transform.translateX, + translateY = transform.translateY, + skewX = transform.skewX, + skewY = transform.skewY, + originX = transform.originX, + originY = transform.originY, + }) +end + +-- ============================================================================ +-- ANIMATION +-- ============================================================================ + ---@class Keyframe ---@field at number Normalized time position (0-1) ---@field values table Property values at this keyframe @@ -41,22 +547,22 @@ Animation.__index = Animation function Animation.new(props) -- Validate input if type(props) ~= "table" then - ErrorHandler.warn("Animation", "Animation.new() requires a table argument. Using default values.") + Animation._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.") + Animation._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.") + Animation._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.") + Animation._ErrorHandler.warn("Animation", "Animation final must be a table. Using empty table.") props.final = {} end @@ -80,7 +586,7 @@ function Animation.new(props) self._paused = false self._reversed = false self._speed = 1.0 - self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled" + self._state = "pending" -- Validate and set easing function local easingName = props.easing or "linear" @@ -130,7 +636,6 @@ function Animation:update(dt, element) 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 @@ -146,7 +651,6 @@ function Animation:update(dt, element) 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 @@ -166,9 +670,7 @@ function Animation:update(dt, element) 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 @@ -176,7 +678,6 @@ function Animation:update(dt, element) self.elapsed = 0 end else - -- Reset to beginning self.elapsed = 0 end return false @@ -185,7 +686,6 @@ function Animation:update(dt, element) -- 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 @@ -226,17 +726,15 @@ end ---@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.") + if Animation._ErrorHandler then + Animation._ErrorHandler.warn("Animation", "Color module not properly initialized. Cannot interpolate colors.") end - return startColor -- Return start color as fallback + return startColor end - -- Parse colors if needed local colorA = CM.parse(startColor) local colorB = CM.parse(finalColor) @@ -251,7 +749,6 @@ end 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 @@ -285,7 +782,6 @@ function Animation:findKeyframes(progress) return nil, nil end - -- Find surrounding keyframes local prevFrame = self.keyframes[1] local nextFrame = self.keyframes[#self.keyframes] @@ -308,7 +804,6 @@ end 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 @@ -317,7 +812,6 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) keys[k] = true end - -- Define properties that should be animated as numbers local numericProperties = { "width", "height", @@ -332,7 +826,6 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) "lineHeight", } - -- Define properties that should be animated as Colors local colorProperties = { "backgroundColor", "borderColor", @@ -342,14 +835,12 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) "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 @@ -365,7 +856,6 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) tableSet[prop] = true end - -- Interpolate each property for key in pairs(keys) do local startVal = prevFrame.values[key] local finalVal = nextFrame.values[key] @@ -379,11 +869,9 @@ function Animation:lerpKeyframes(prevFrame, nextFrame, easedT) 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 @@ -396,7 +884,7 @@ end --- 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) + -- Return cached result if not dirty if not self._resultDirty then return self._cachedResult end @@ -408,13 +896,11 @@ function Animation:interpolate() 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 @@ -429,10 +915,8 @@ function Animation:interpolate() 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 @@ -447,20 +931,17 @@ function Animation:interpolate() 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 + easedT = t end - local result = self._cachedResult -- Reuse existing table + local result = self._cachedResult - -- 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", @@ -475,7 +956,6 @@ function Animation:interpolate() "lineHeight", } - -- Define properties that should be animated as Colors local colorProperties = { "backgroundColor", "borderColor", @@ -485,14 +965,12 @@ function Animation:interpolate() "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] @@ -502,7 +980,6 @@ function Animation:interpolate() 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 @@ -515,7 +992,6 @@ function Animation:interpolate() end end - -- Interpolate table properties for _, prop in ipairs(tableProperties) do local startVal = self.start[prop] local finalVal = self.final[prop] @@ -525,12 +1001,10 @@ function Animation:interpolate() 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 @@ -545,29 +1019,13 @@ end --- 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.") + Animation._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() @@ -676,10 +1134,6 @@ end ---@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 @@ -687,7 +1141,7 @@ function Animation:chain(nextAnimation) self._next = nextAnimation return nextAnimation else - ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.") + Animation._ErrorHandler.warn("Animation", "chain() requires an Animation or function. Chaining not applied.") return self end end @@ -697,12 +1151,8 @@ end ---@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.") + Animation._ErrorHandler.warn("Animation", "delay() requires a non-negative number. Using 0.") seconds = 0 end self._delay = seconds @@ -715,12 +1165,8 @@ end ---@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.") + Animation._ErrorHandler.warn("Animation", "repeatCount() requires a non-negative number. Using 0.") count = 0 end self._repeatCount = count @@ -748,7 +1194,6 @@ end ---@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 @@ -777,7 +1222,6 @@ end ---@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 @@ -803,30 +1247,24 @@ end ---@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.") + Animation._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.") + Animation._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.") + Animation._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 @@ -838,7 +1276,6 @@ function Animation.keyframes(props) 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 }) @@ -848,7 +1285,6 @@ function Animation.keyframes(props) end end - -- Create animation with keyframes return Animation.new({ duration = props.duration, start = {}, @@ -865,18 +1301,345 @@ end ---@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 + Animation._ErrorHandler = deps.ErrorHandler + Animation._Easing = deps.Easing + Animation._ColorModule = deps.Color + Animation._Transform = Transform + end +end + +-- ============================================================================ +-- ANIMATION GROUP +-- ============================================================================ + +---@class AnimationGroup +local AnimationGroup = {} +AnimationGroup.__index = AnimationGroup + +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) + +--- Coordinate multiple animations to play together, in sequence, or staggered for complex choreographed effects +--- Use this to synchronize related UI changes like simultaneous fades or sequential reveals +---@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 + + 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 + + self._currentIndex = 1 + self._staggerElapsed = 0 + self._startedAnimations = {} + self._hasStarted = false + self._paused = false + self._state = "ready" + + 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 + 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 + + 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 + + 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 + +--- Advance all animations in the group according to their coordination mode +--- Call this each frame to progress parallel, sequential, or staggered animations +---@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) + 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 + + 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 + +--- Freeze the entire animation sequence in unison +--- Use this to pause complex multi-part animations during game pauses +function AnimationGroup:pause() + self._paused = true + for _, anim in ipairs(self.animations) do + if type(anim.pause) == "function" then + anim:pause() end end end --- Static method for Color module injection (for per-instance Color override) -function Animation.setColorModule(ColorModule) - Animation._ColorModule = ColorModule +--- Continue all paused animations simultaneously from their paused states +--- Use this to unpause coordinated animation sequences +function AnimationGroup:resume() + self._paused = false + for _, anim in ipairs(self.animations) do + if type(anim.resume) == "function" then + anim:resume() + end + end end +--- Determine if the entire group is currently paused +--- Use this to sync other game logic with animation group state +---@return boolean paused +function AnimationGroup:isPaused() + return self._paused +end + +--- Flip all animations to play backwards together +--- Use this to reverse complex transitions like panel opens/closes +function AnimationGroup:reverse() + for _, anim in ipairs(self.animations) do + if type(anim.reverse) == "function" then + anim:reverse() + end + end +end + +--- Control the tempo of all animations simultaneously +--- Use this for slow-motion effects or debugging without adjusting individual animations +---@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 + +--- Abort all animations in the group immediately without completion +--- Use this when UI is dismissed mid-animation or transitions are interrupted +---@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 + +--- Restart the entire group from the beginning for reuse +--- Use this to replay animation sequences without recreating objects +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 + +--- Check the overall lifecycle state of the animation group +--- Use this to conditionally trigger follow-up actions or cleanup +---@return string state "ready", "playing", "completed", "cancelled" +function AnimationGroup:getState() + return self._state +end + +--- Calculate completion percentage across all animations in the group +--- Use this for progress bars or to synchronize other effects with the group +---@return number progress +function AnimationGroup:getProgress() + if #self.animations == 0 then + return 1 + end + + if self.mode == "sequence" then + 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 + 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 + +--- Attach this group to an element for automatic updates and integration +--- Use this for hands-off animation management within FlexLove's system +---@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 dependencies +---@param deps table Dependencies: { ErrorHandler = ErrorHandler } +function AnimationGroup.init(deps) + if type(deps) == "table" then + ErrorHandler = deps.ErrorHandler + end +end + +Animation.Easing = Easing +Animation.Transform = Transform +Animation.AnimationGroup = AnimationGroup + return Animation diff --git a/modules/AnimationGroup.lua b/modules/AnimationGroup.lua deleted file mode 100644 index c064331..0000000 --- a/modules/AnimationGroup.lua +++ /dev/null @@ -1,338 +0,0 @@ ---- 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) - ---- Coordinate multiple animations to play together, in sequence, or staggered for complex choreographed effects ---- Use this to synchronize related UI changes like simultaneous fades or sequential reveals ----@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 - ---- Advance all animations in the group according to their coordination mode ---- Call this each frame to progress parallel, sequential, or staggered animations ----@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 - ---- Freeze the entire animation sequence in unison ---- Use this to pause complex multi-part animations during game pauses -function AnimationGroup:pause() - self._paused = true - for _, anim in ipairs(self.animations) do - if type(anim.pause) == "function" then - anim:pause() - end - end -end - ---- Continue all paused animations simultaneously from their paused states ---- Use this to unpause coordinated animation sequences -function AnimationGroup:resume() - self._paused = false - for _, anim in ipairs(self.animations) do - if type(anim.resume) == "function" then - anim:resume() - end - end -end - ---- Determine if the entire group is currently paused ---- Use this to sync other game logic with animation group state ----@return boolean paused -function AnimationGroup:isPaused() - return self._paused -end - ---- Flip all animations to play backwards together ---- Use this to reverse complex transitions like panel opens/closes -function AnimationGroup:reverse() - for _, anim in ipairs(self.animations) do - if type(anim.reverse) == "function" then - anim:reverse() - end - end -end - ---- Control the tempo of all animations simultaneously ---- Use this for slow-motion effects or debugging without adjusting individual animations ----@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 - ---- Abort all animations in the group immediately without completion ---- Use this when UI is dismissed mid-animation or transitions are interrupted ----@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 - ---- Restart the entire group from the beginning for reuse ---- Use this to replay animation sequences without recreating objects -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 - ---- Check the overall lifecycle state of the animation group ---- Use this to conditionally trigger follow-up actions or cleanup ----@return string state "ready", "playing", "completed", "cancelled" -function AnimationGroup:getState() - return self._state -end - ---- Calculate completion percentage across all animations in the group ---- Use this for progress bars or to synchronize other effects with the group ----@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 - ---- Attach this group to an element for automatic updates and integration ---- Use this for hands-off animation management within FlexLove's system ----@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 dependencies ----@param deps table Dependencies: { ErrorHandler = ErrorHandler } -function AnimationGroup.init(deps) - if type(deps) == "table" then - ErrorHandler = deps.ErrorHandler - end -end - -return AnimationGroup diff --git a/modules/Color.lua b/modules/Color.lua index e2fa83d..c1c72ba 100644 --- a/modules/Color.lua +++ b/modules/Color.lua @@ -1,41 +1,33 @@ local ErrorHandler = nil ---- Standardized error message formatter (fallback for when ErrorHandler not available) ----@param module string -- Module name (e.g., "Color", "Theme", "Units") ----@param message string ----@return string -local function formatError(module, message) - return string.format("[FlexLove.%s] %s", module, message) -end - -- Named colors (CSS3 color names) local NAMED_COLORS = { -- Basic colors - black = {0, 0, 0, 1}, - white = {1, 1, 1, 1}, - red = {1, 0, 0, 1}, - green = {0, 0.502, 0, 1}, - blue = {0, 0, 1, 1}, - yellow = {1, 1, 0, 1}, - cyan = {0, 1, 1, 1}, - magenta = {1, 0, 1, 1}, - + black = { 0, 0, 0, 1 }, + white = { 1, 1, 1, 1 }, + red = { 1, 0, 0, 1 }, + green = { 0, 0.502, 0, 1 }, + blue = { 0, 0, 1, 1 }, + yellow = { 1, 1, 0, 1 }, + cyan = { 0, 1, 1, 1 }, + magenta = { 1, 0, 1, 1 }, + -- Extended colors - gray = {0.502, 0.502, 0.502, 1}, - grey = {0.502, 0.502, 0.502, 1}, - silver = {0.753, 0.753, 0.753, 1}, - maroon = {0.502, 0, 0, 1}, - olive = {0.502, 0.502, 0, 1}, - lime = {0, 1, 0, 1}, - aqua = {0, 1, 1, 1}, - teal = {0, 0.502, 0.502, 1}, - navy = {0, 0, 0.502, 1}, - fuchsia = {1, 0, 1, 1}, - purple = {0.502, 0, 0.502, 1}, - orange = {1, 0.647, 0, 1}, - pink = {1, 0.753, 0.796, 1}, - brown = {0.647, 0.165, 0.165, 1}, - transparent = {0, 0, 0, 0}, + gray = { 0.502, 0.502, 0.502, 1 }, + grey = { 0.502, 0.502, 0.502, 1 }, + silver = { 0.753, 0.753, 0.753, 1 }, + maroon = { 0.502, 0, 0, 1 }, + olive = { 0.502, 0.502, 0, 1 }, + lime = { 0, 1, 0, 1 }, + aqua = { 0, 1, 1, 1 }, + teal = { 0, 0.502, 0.502, 1 }, + navy = { 0, 0, 0.502, 1 }, + fuchsia = { 1, 0, 1, 1 }, + purple = { 0.502, 0, 0.502, 1 }, + orange = { 1, 0.647, 0, 1 }, + pink = { 1, 0.753, 0.796, 1 }, + brown = { 0.647, 0.165, 0.165, 1 }, + transparent = { 0, 0, 0, 0 }, } --- Utility class for color handling @@ -56,13 +48,13 @@ Color.__index = Color ---@return Color color The new color instance function Color.new(r, g, b, a) local self = setmetatable({}, Color) - + -- Sanitize and clamp color components local _, sanitizedR = Color.validateColorChannel(r or 0, 1) local _, sanitizedG = Color.validateColorChannel(g or 0, 1) local _, sanitizedB = Color.validateColorChannel(b or 0, 1) local _, sanitizedA = Color.validateColorChannel(a or 1, 1) - + self.r = sanitizedR or 0 self.g = sanitizedG or 0 self.b = sanitizedB or 0 @@ -91,12 +83,12 @@ function Color.fromHex(hexWithTag) ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = tostring(hexWithTag), issue = "not a string", - fallback = "white (#FFFFFF)" + fallback = "white (#FFFFFF)", }) end return Color.new(1, 1, 1, 1) end - + local hex = hexWithTag:gsub("#", "") if #hex == 6 then local r = tonumber("0x" .. hex:sub(1, 2)) @@ -107,7 +99,7 @@ function Color.fromHex(hexWithTag) ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = hexWithTag, issue = "invalid hex digits", - fallback = "white (#FFFFFF)" + fallback = "white (#FFFFFF)", }) end return Color.new(1, 1, 1, 1) -- Return white as fallback @@ -123,7 +115,7 @@ function Color.fromHex(hexWithTag) ErrorHandler.warn("Color", "VAL_004", "Invalid color format", { input = hexWithTag, issue = "invalid hex digits", - fallback = "white (#FFFFFFFF)" + fallback = "white (#FFFFFFFF)", }) end return Color.new(1, 1, 1, 1) -- Return white as fallback @@ -135,7 +127,7 @@ function Color.fromHex(hexWithTag) input = hexWithTag, expected = "#RRGGBB or #RRGGBBAA", hexLength = #hex, - fallback = "white (#FFFFFF)" + fallback = "white (#FFFFFF)", }) end return Color.new(1, 1, 1, 1) -- Return white as fallback @@ -150,30 +142,30 @@ end ---@return number? clamped Clamped value in 0-1 range, nil if invalid function Color.validateColorChannel(value, max) max = max or 1 - + if type(value) ~= "number" then return false, nil end - + -- Check for NaN if value ~= value then return false, nil end - + -- Check for Infinity if value == math.huge or value == -math.huge then return false, nil end - + -- Normalize to 0-1 range local normalized = value if max == 255 then normalized = value / 255 end - + -- Clamp to valid range normalized = math.max(0, math.min(1, normalized)) - + return true, normalized end @@ -185,20 +177,20 @@ function Color.validateHexColor(hex) if type(hex) ~= "string" then return false, "Hex color must be a string" end - + -- Remove # prefix local cleanHex = hex:gsub("^#", "") - + -- Check length (3, 6, or 8 characters) if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex) end - + -- Check for valid hex characters if not cleanHex:match("^[0-9A-Fa-f]+$") then return false, "Invalid hex characters. Use only 0-9, A-F" end - + return true, nil end @@ -213,12 +205,12 @@ end function Color.validateRGBColor(r, g, b, a, max) max = max or 1 a = a or max - + local rValid = Color.validateColorChannel(r, max) local gValid = Color.validateColorChannel(g, max) local bValid = Color.validateColorChannel(b, max) local aValid = Color.validateColorChannel(a, max) - + if not rValid then return false, string.format("Invalid red channel: %s", tostring(r)) end @@ -231,7 +223,7 @@ function Color.validateRGBColor(r, g, b, a, max) if not aValid then return false, string.format("Invalid alpha channel: %s", tostring(a)) end - + return true, nil end @@ -243,12 +235,12 @@ function Color.validateNamedColor(name) if type(name) ~= "string" then return false, "Color name must be a string" end - + local lowerName = name:lower() if not NAMED_COLORS[lowerName] then return false, string.format("Unknown color name: '%s'", name) end - + return true, nil end @@ -257,7 +249,7 @@ end ---@return string? format Format type ("hex", "named", "table"), nil if invalid function Color.isValidColorFormat(value) local valueType = type(value) - + -- Check for hex string if valueType == "string" then if value:match("^#?[0-9A-Fa-f]+$") then @@ -266,22 +258,22 @@ function Color.isValidColorFormat(value) return "hex" end end - + -- Check for named color if NAMED_COLORS[value:lower()] then return "named" end - + return nil end - + -- Check for table format if valueType == "table" then -- Check for Color instance if getmetatable(value) == Color then return "table" end - + -- Check for array format {r, g, b, a} if value[1] and value[2] and value[3] then local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4]) @@ -289,7 +281,7 @@ function Color.isValidColorFormat(value) return "table" end end - + -- Check for named format {r=, g=, b=, a=} if value.r and value.g and value.b then local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a) @@ -297,10 +289,10 @@ function Color.isValidColorFormat(value) return "table" end end - + return nil end - + return nil end @@ -314,21 +306,21 @@ function Color.validateColor(value, options) options = options or {} local allowNamed = options.allowNamed ~= false local requireAlpha = options.requireAlpha or false - + if value == nil then return false, "Color value is nil" end - + local format = Color.isValidColorFormat(value) - + if not format then return false, string.format("Invalid color format: %s", tostring(value)) end - + if format == "named" and not allowNamed then return false, "Named colors not allowed" end - + -- Additional validation for alpha requirement if requireAlpha and format == "hex" then local cleanHex = value:gsub("^#", "") @@ -336,7 +328,7 @@ function Color.validateColor(value, options) return false, "Alpha channel required (use 8-digit hex)" end end - + return true, nil end @@ -347,22 +339,22 @@ end ---@return Color color Sanitized color instance (guaranteed non-nil) function Color.sanitizeColor(value, default) default = default or Color.new(0, 0, 0, 1) - + local format = Color.isValidColorFormat(value) - + if not format then return default end - + -- Handle hex format if format == "hex" then local cleanHex = value:gsub("^#", "") - + -- Expand 3-digit hex to 6-digit if #cleanHex == 3 then cleanHex = cleanHex:gsub("(.)", "%1%1") end - + -- Try to parse local success, result = pcall(Color.fromHex, "#" .. cleanHex) if success then @@ -371,7 +363,7 @@ function Color.sanitizeColor(value, default) return default end end - + -- Handle named format if format == "named" then local lowerName = value:lower() @@ -381,39 +373,39 @@ function Color.sanitizeColor(value, default) end return default end - + -- Handle table format if format == "table" then -- Color instance if getmetatable(value) == Color then return value end - + -- Array format if value[1] then local _, r = Color.validateColorChannel(value[1], 1) local _, g = Color.validateColorChannel(value[2], 1) local _, b = Color.validateColorChannel(value[3], 1) local _, a = Color.validateColorChannel(value[4] or 1, 1) - + if r and g and b and a then return Color.new(r, g, b, a) end end - + -- Named format if value.r then local _, r = Color.validateColorChannel(value.r, 1) local _, g = Color.validateColorChannel(value.g, 1) local _, b = Color.validateColorChannel(value.b, 1) local _, a = Color.validateColorChannel(value.a or 1, 1) - + if r and g and b and a then return Color.new(r, g, b, a) end end end - + return default end @@ -442,16 +434,16 @@ function Color.lerp(colorA, colorB, t) if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then t = 0 end - + -- Clamp t to 0-1 range t = math.max(0, math.min(1, t)) - + -- Linear interpolation for each channel local r = colorA.r * (1 - t) + colorB.r * t local g = colorA.g * (1 - t) + colorB.g * t local b = colorA.b * (1 - t) + colorB.b * t local a = colorA.a * (1 - t) + colorB.a * t - + return Color.new(r, g, b, a) end diff --git a/modules/Easing.lua b/modules/Easing.lua deleted file mode 100644 index 1d3629a..0000000 --- a/modules/Easing.lua +++ /dev/null @@ -1,361 +0,0 @@ ---- Easing functions for animations ---- Provides 30+ easing functions for smooth animation transitions ---- ---- Easing function type ----@alias EasingFunction fun(t: number): number ---- ----@class Easing -local Easing = {} - --- ============================================================================ --- Linear --- ============================================================================ - ----@type EasingFunction -function Easing.linear(t) - return t -end - --- ============================================================================ --- Quadratic (Quad) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInQuad(t) - return t * t -end - ----@type EasingFunction -function Easing.easeOutQuad(t) - return t * (2 - t) -end - ----@type EasingFunction -function Easing.easeInOutQuad(t) - return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t -end - --- ============================================================================ --- Cubic --- ============================================================================ - ----@type EasingFunction -function Easing.easeInCubic(t) - return t * t * t -end - ----@type EasingFunction -function Easing.easeOutCubic(t) - local t1 = t - 1 - return t1 * t1 * t1 + 1 -end - ----@type EasingFunction -function Easing.easeInOutCubic(t) - return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 -end - --- ============================================================================ --- Quartic (Quart) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInQuart(t) - return t * t * t * t -end - ----@type EasingFunction -function Easing.easeOutQuart(t) - local t1 = t - 1 - return 1 - t1 * t1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutQuart(t) - if t < 0.5 then - return 8 * t * t * t * t - else - local t1 = t - 1 - return 1 - 8 * t1 * t1 * t1 * t1 - end -end - --- ============================================================================ --- Quintic (Quint) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInQuint(t) - return t * t * t * t * t -end - ----@type EasingFunction -function Easing.easeOutQuint(t) - local t1 = t - 1 - return 1 + t1 * t1 * t1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutQuint(t) - if t < 0.5 then - return 16 * t * t * t * t * t - else - local t1 = t - 1 - return 1 + 16 * t1 * t1 * t1 * t1 * t1 - end -end - --- ============================================================================ --- Exponential (Expo) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInExpo(t) - return t == 0 and 0 or math.pow(2, 10 * (t - 1)) -end - ----@type EasingFunction -function Easing.easeOutExpo(t) - return t == 1 and 1 or 1 - math.pow(2, -10 * t) -end - ----@type EasingFunction -function Easing.easeInOutExpo(t) - if t == 0 then return 0 end - if t == 1 then return 1 end - - if t < 0.5 then - return 0.5 * math.pow(2, 20 * t - 10) - else - return 1 - 0.5 * math.pow(2, -20 * t + 10) - end -end - --- ============================================================================ --- Sine --- ============================================================================ - ----@type EasingFunction -function Easing.easeInSine(t) - return 1 - math.cos(t * math.pi / 2) -end - ----@type EasingFunction -function Easing.easeOutSine(t) - return math.sin(t * math.pi / 2) -end - ----@type EasingFunction -function Easing.easeInOutSine(t) - return -(math.cos(math.pi * t) - 1) / 2 -end - --- ============================================================================ --- Circular (Circ) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInCirc(t) - return 1 - math.sqrt(1 - t * t) -end - ----@type EasingFunction -function Easing.easeOutCirc(t) - local t1 = t - 1 - return math.sqrt(1 - t1 * t1) -end - ----@type EasingFunction -function Easing.easeInOutCirc(t) - if t < 0.5 then - return (1 - math.sqrt(1 - 4 * t * t)) / 2 - else - local t1 = -2 * t + 2 - return (math.sqrt(1 - t1 * t1) + 1) / 2 - end -end - --- ============================================================================ --- Back (Overshoot) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInBack(t) - local c1 = 1.70158 - local c3 = c1 + 1 - return c3 * t * t * t - c1 * t * t -end - ----@type EasingFunction -function Easing.easeOutBack(t) - local c1 = 1.70158 - local c3 = c1 + 1 - local t1 = t - 1 - return 1 + c3 * t1 * t1 * t1 + c1 * t1 * t1 -end - ----@type EasingFunction -function Easing.easeInOutBack(t) - local c1 = 1.70158 - local c2 = c1 * 1.525 - - if t < 0.5 then - return (2 * t * 2 * t * ((c2 + 1) * 2 * t - c2)) / 2 - else - local t1 = 2 * t - 2 - return (t1 * t1 * ((c2 + 1) * t1 + c2) + 2) / 2 - end -end - --- ============================================================================ --- Elastic (Spring) --- ============================================================================ - ----@type EasingFunction -function Easing.easeInElastic(t) - if t == 0 then return 0 end - if t == 1 then return 1 end - - local c4 = (2 * math.pi) / 3 - return -math.pow(2, 10 * t - 10) * math.sin((t * 10 - 10.75) * c4) -end - ----@type EasingFunction -function Easing.easeOutElastic(t) - if t == 0 then return 0 end - if t == 1 then return 1 end - - local c4 = (2 * math.pi) / 3 - return math.pow(2, -10 * t) * math.sin((t * 10 - 0.75) * c4) + 1 -end - ----@type EasingFunction -function Easing.easeInOutElastic(t) - if t == 0 then return 0 end - if t == 1 then return 1 end - - local c5 = (2 * math.pi) / 4.5 - - if t < 0.5 then - return -(math.pow(2, 20 * t - 10) * math.sin((20 * t - 11.125) * c5)) / 2 - else - return (math.pow(2, -20 * t + 10) * math.sin((20 * t - 11.125) * c5)) / 2 + 1 - end -end - --- ============================================================================ --- Bounce --- ============================================================================ - ----@type EasingFunction -function Easing.easeOutBounce(t) - local n1 = 7.5625 - local d1 = 2.75 - - if t < 1 / d1 then - return n1 * t * t - elseif t < 2 / d1 then - local t1 = t - 1.5 / d1 - return n1 * t1 * t1 + 0.75 - elseif t < 2.5 / d1 then - local t1 = t - 2.25 / d1 - return n1 * t1 * t1 + 0.9375 - else - local t1 = t - 2.625 / d1 - return n1 * t1 * t1 + 0.984375 - end -end - ----@type EasingFunction -function Easing.easeInBounce(t) - return 1 - Easing.easeOutBounce(1 - t) -end - ----@type EasingFunction -function Easing.easeInOutBounce(t) - if t < 0.5 then - return (1 - Easing.easeOutBounce(1 - 2 * t)) / 2 - else - return (1 + Easing.easeOutBounce(2 * t - 1)) / 2 - end -end - --- ============================================================================ --- Configurable Easing Factories --- ============================================================================ - ---- Create a custom back easing function with configurable overshoot ----@param overshoot number? Overshoot amount (default: 1.70158) ----@return EasingFunction -function Easing.back(overshoot) - overshoot = overshoot or 1.70158 - local c3 = overshoot + 1 - - return function(t) - return c3 * t * t * t - overshoot * t * t - end -end - ---- Create a custom elastic easing function ----@param amplitude number? Amplitude (default: 1) ----@param period number? Period (default: 0.3) ----@return EasingFunction -function Easing.elastic(amplitude, period) - amplitude = amplitude or 1 - period = period or 0.3 - - return function(t) - if t == 0 then return 0 end - if t == 1 then return 1 end - - local s = period / 4 - local a = amplitude - - if a < 1 then - a = 1 - s = period / 4 - else - s = period / (2 * math.pi) * math.asin(1 / a) - end - - return a * math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / period) + 1 - end -end - ---- Get list of all available easing function names ----@return string[] names Array of easing function names -function Easing.list() - return { - -- Linear - "linear", - -- Quad - "easeInQuad", "easeOutQuad", "easeInOutQuad", - -- Cubic - "easeInCubic", "easeOutCubic", "easeInOutCubic", - -- Quart - "easeInQuart", "easeOutQuart", "easeInOutQuart", - -- Quint - "easeInQuint", "easeOutQuint", "easeInOutQuint", - -- Expo - "easeInExpo", "easeOutExpo", "easeInOutExpo", - -- Sine - "easeInSine", "easeOutSine", "easeInOutSine", - -- Circ - "easeInCirc", "easeOutCirc", "easeInOutCirc", - -- Back - "easeInBack", "easeOutBack", "easeInOutBack", - -- Elastic - "easeInElastic", "easeOutElastic", "easeInOutElastic", - -- Bounce - "easeInBounce", "easeOutBounce", "easeInOutBounce", - } -end - ---- Get an easing function by name ----@param name string Easing function name ----@return EasingFunction? easing The easing function, or nil if not found -function Easing.get(name) - return Easing[name] -end - -return Easing diff --git a/modules/Element.lua b/modules/Element.lua index 7075a3d..ae45302 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -153,8 +153,6 @@ local Element = {} Element.__index = Element --- Note: Element.defaultDependencies is now defined in FlexLove.lua - ---@param props ElementProps ---@param deps table Required dependency table (provided by FlexLove) ---@return Element @@ -3015,23 +3013,23 @@ 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 + onComplete = config.onComplete, } end @@ -3044,7 +3042,7 @@ function Element:setTransitionGroup(groupName, config, properties) 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 @@ -3056,7 +3054,7 @@ function Element:removeTransition(property) if not self.transitions then return end - + if property == "all" then self.transitions = {} else @@ -3071,21 +3069,21 @@ 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 @@ -3095,24 +3093,24 @@ function Element:setProperty(property, value) start = { [property] = currentValue }, final = { [property] = value }, easing = transitionConfig.easing, - onComplete = transitionConfig.onComplete + 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 diff --git a/modules/ErrorCodes.lua b/modules/ErrorCodes.lua deleted file mode 100644 index 83f5ac1..0000000 --- a/modules/ErrorCodes.lua +++ /dev/null @@ -1,473 +0,0 @@ ----@class ErrorCodes -local ErrorCodes = {} - --- Error code categories -ErrorCodes.categories = { - VAL = "Validation", - LAY = "Layout", - REN = "Render", - THM = "Theme", - EVT = "Event", - RES = "Resource", - SYS = "System", -} - --- Error code definitions -ErrorCodes.codes = { - -- Validation Errors (VAL_001 - VAL_099) - VAL_001 = { - code = "FLEXLOVE_VAL_001", - category = "VAL", - description = "Invalid property type", - suggestion = "Check the property type matches the expected type (e.g., number, string, table)", - }, - VAL_002 = { - code = "FLEXLOVE_VAL_002", - category = "VAL", - description = "Property value out of range", - suggestion = "Ensure the value is within the allowed min/max range", - }, - VAL_003 = { - code = "FLEXLOVE_VAL_003", - category = "VAL", - description = "Required property missing", - suggestion = "Provide the required property in your element definition", - }, - VAL_004 = { - code = "FLEXLOVE_VAL_004", - category = "VAL", - description = "Invalid color format", - suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object", - }, - VAL_005 = { - code = "FLEXLOVE_VAL_005", - category = "VAL", - description = "Invalid unit format", - suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.", - }, - VAL_006 = { - code = "FLEXLOVE_VAL_006", - category = "VAL", - description = "Invalid file path", - suggestion = "Check that the file path is correct and the file exists", - }, - VAL_007 = { - code = "FLEXLOVE_VAL_007", - category = "VAL", - description = "Invalid enum value", - suggestion = "Use one of the allowed enum values for this property", - }, - VAL_008 = { - code = "FLEXLOVE_VAL_008", - category = "VAL", - description = "Invalid text input", - suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)", - }, - - -- Layout Errors (LAY_001 - LAY_099) - LAY_001 = { - code = "FLEXLOVE_LAY_001", - category = "LAY", - description = "Invalid flex direction", - suggestion = "Use 'horizontal' or 'vertical' for flexDirection", - }, - LAY_002 = { - code = "FLEXLOVE_LAY_002", - category = "LAY", - description = "Circular dependency detected", - suggestion = "Remove circular references in element hierarchy or layout constraints", - }, - LAY_003 = { - code = "FLEXLOVE_LAY_003", - category = "LAY", - description = "Invalid dimensions (negative or NaN)", - suggestion = "Ensure width and height are positive numbers", - }, - LAY_004 = { - code = "FLEXLOVE_LAY_004", - category = "LAY", - description = "Layout calculation overflow", - suggestion = "Reduce complexity of layout or increase recursion limit", - }, - LAY_005 = { - code = "FLEXLOVE_LAY_005", - category = "LAY", - description = "Invalid alignment value", - suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)", - }, - LAY_006 = { - code = "FLEXLOVE_LAY_006", - category = "LAY", - description = "Invalid positioning mode", - suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning", - }, - LAY_007 = { - code = "FLEXLOVE_LAY_007", - category = "LAY", - description = "Grid layout error", - suggestion = "Check grid template columns/rows and item placement", - }, - - -- Rendering Errors (REN_001 - REN_099) - REN_001 = { - code = "FLEXLOVE_REN_001", - category = "REN", - description = "Invalid render state", - suggestion = "Ensure element is properly initialized before rendering", - }, - REN_002 = { - code = "FLEXLOVE_REN_002", - category = "REN", - description = "Texture loading failed", - suggestion = "Check image path and format, ensure file exists", - }, - REN_003 = { - code = "FLEXLOVE_REN_003", - category = "REN", - description = "Font loading failed", - suggestion = "Check font path and format, ensure file exists", - }, - REN_004 = { - code = "FLEXLOVE_REN_004", - category = "REN", - description = "Invalid color value", - suggestion = "Color components must be numbers between 0 and 1", - }, - REN_005 = { - code = "FLEXLOVE_REN_005", - category = "REN", - description = "Clipping stack overflow", - suggestion = "Reduce nesting depth or check for missing scissor pops", - }, - REN_006 = { - code = "FLEXLOVE_REN_006", - category = "REN", - description = "Shader compilation failed", - suggestion = "Check shader code for syntax errors", - }, - REN_007 = { - code = "FLEXLOVE_REN_007", - category = "REN", - description = "Invalid nine-patch configuration", - suggestion = "Check nine-patch slice values and image dimensions", - }, - - -- Theme Errors (THM_001 - THM_099) - THM_001 = { - code = "FLEXLOVE_THM_001", - category = "THM", - description = "Theme file not found", - suggestion = "Check theme file path and ensure file exists", - }, - THM_002 = { - code = "FLEXLOVE_THM_002", - category = "THM", - description = "Invalid theme structure", - suggestion = "Theme must return a table with 'name' and component styles", - }, - THM_003 = { - code = "FLEXLOVE_THM_003", - category = "THM", - description = "Required theme property missing", - suggestion = "Ensure theme has required properties (name, base styles, etc.)", - }, - THM_004 = { - code = "FLEXLOVE_THM_004", - category = "THM", - description = "Invalid component style", - suggestion = "Component styles must be tables with valid properties", - }, - THM_005 = { - code = "FLEXLOVE_THM_005", - category = "THM", - description = "Theme loading failed", - suggestion = "Check theme file for Lua syntax errors", - }, - THM_006 = { - code = "FLEXLOVE_THM_006", - category = "THM", - description = "Invalid theme color", - suggestion = "Theme colors must be valid color values (hex, rgba, Color object)", - }, - - -- Event Errors (EVT_001 - EVT_099) - EVT_001 = { - code = "FLEXLOVE_EVT_001", - category = "EVT", - description = "Invalid event type", - suggestion = "Use valid event types (mousepressed, textinput, etc.)", - }, - EVT_002 = { - code = "FLEXLOVE_EVT_002", - category = "EVT", - description = "Event handler error", - suggestion = "Check event handler function for errors", - }, - EVT_003 = { - code = "FLEXLOVE_EVT_003", - category = "EVT", - description = "Event propagation error", - suggestion = "Check event bubbling/capturing logic", - }, - EVT_004 = { - code = "FLEXLOVE_EVT_004", - category = "EVT", - description = "Invalid event target", - suggestion = "Ensure event target element exists and is valid", - }, - EVT_005 = { - code = "FLEXLOVE_EVT_005", - category = "EVT", - description = "Event handler not a function", - suggestion = "Event handlers must be functions", - }, - - -- Resource Errors (RES_001 - RES_099) - RES_001 = { - code = "FLEXLOVE_RES_001", - category = "RES", - description = "File not found", - suggestion = "Check file path and ensure file exists in the filesystem", - }, - RES_002 = { - code = "FLEXLOVE_RES_002", - category = "RES", - description = "Permission denied", - suggestion = "Check file permissions and access rights", - }, - RES_003 = { - code = "FLEXLOVE_RES_003", - category = "RES", - description = "Invalid file format", - suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)", - }, - RES_004 = { - code = "FLEXLOVE_RES_004", - category = "RES", - description = "Resource loading failed", - suggestion = "Check file integrity and format compatibility", - }, - RES_005 = { - code = "FLEXLOVE_RES_005", - category = "RES", - description = "Image cache error", - suggestion = "Clear image cache or check memory availability", - }, - - -- System Errors (SYS_001 - SYS_099) - SYS_001 = { - code = "FLEXLOVE_SYS_001", - category = "SYS", - description = "Memory allocation failed", - suggestion = "Reduce memory usage or check available memory", - }, - SYS_002 = { - code = "FLEXLOVE_SYS_002", - category = "SYS", - description = "Stack overflow", - suggestion = "Reduce recursion depth or check for infinite loops", - }, - SYS_003 = { - code = "FLEXLOVE_SYS_003", - category = "SYS", - description = "Invalid state", - suggestion = "Check initialization order and state management", - }, - SYS_004 = { - code = "FLEXLOVE_SYS_004", - category = "SYS", - description = "Module initialization failed", - suggestion = "Check module dependencies and initialization order", - }, - - -- Performance Warnings (PERF_001 - PERF_099) - PERF_001 = { - code = "FLEXLOVE_PERF_001", - category = "PERF", - description = "Performance threshold exceeded", - suggestion = "Operation took longer than recommended. Monitor for patterns.", - }, - PERF_002 = { - code = "FLEXLOVE_PERF_002", - category = "PERF", - description = "Critical performance threshold exceeded", - suggestion = "Operation is causing frame drops. Consider optimizing or reducing frequency.", - }, - - -- Memory Warnings (MEM_001 - MEM_099) - MEM_001 = { - code = "FLEXLOVE_MEM_001", - category = "MEM", - description = "Memory leak detected", - suggestion = "Table is growing consistently. Review cache eviction policies and ensure objects are properly released.", - }, - - -- State Management Warnings (STATE_001 - STATE_099) - STATE_001 = { - code = "FLEXLOVE_STATE_001", - category = "STATE", - description = "CallSite counters accumulating", - suggestion = "This indicates incrementFrame() may not be called properly. Check immediate mode frame management.", - }, -} - ---- Get error information by code ---- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001") ---- @return table? errorInfo Error information or nil if not found -function ErrorCodes.get(code) - -- Handle both short and full format - local shortCode = code:gsub("^FLEXLOVE_", "") - return ErrorCodes.codes[shortCode] -end - ---- Get human-readable description for error code ---- @param code string Error code ---- @return string description Error description -function ErrorCodes.describe(code) - local info = ErrorCodes.get(code) - if info then - return info.description - end - return "Unknown error code: " .. code -end - ---- Get suggested fix for error code ---- @param code string Error code ---- @return string suggestion Suggested fix -function ErrorCodes.getSuggestion(code) - local info = ErrorCodes.get(code) - if info then - return info.suggestion - end - return "No suggestion available" -end - ---- Get category for error code ---- @param code string Error code ---- @return string category Error category name -function ErrorCodes.getCategory(code) - local info = ErrorCodes.get(code) - if info then - return ErrorCodes.categories[info.category] or info.category - end - return "Unknown" -end - ---- List all error codes in a category ---- @param category string Category code (e.g., "VAL", "LAY") ---- @return table codes List of error codes in category -function ErrorCodes.listByCategory(category) - local result = {} - for code, info in pairs(ErrorCodes.codes) do - if info.category == category then - table.insert(result, { - code = code, - fullCode = info.code, - description = info.description, - suggestion = info.suggestion, - }) - end - end - table.sort(result, function(a, b) - return a.code < b.code - end) - return result -end - ---- Search error codes by keyword ---- @param keyword string Keyword to search for ---- @return table codes Matching error codes -function ErrorCodes.search(keyword) - keyword = keyword:lower() - local result = {} - for code, info in pairs(ErrorCodes.codes) do - local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower() - if searchText:find(keyword, 1, true) then - table.insert(result, { - code = code, - fullCode = info.code, - description = info.description, - suggestion = info.suggestion, - category = ErrorCodes.categories[info.category], - }) - end - end - return result -end - ---- Get all error codes ---- @return table codes All error codes -function ErrorCodes.listAll() - local result = {} - for code, info in pairs(ErrorCodes.codes) do - table.insert(result, { - code = code, - fullCode = info.code, - description = info.description, - suggestion = info.suggestion, - category = ErrorCodes.categories[info.category], - }) - end - table.sort(result, function(a, b) - return a.code < b.code - end) - return result -end - ---- Format error message with code ---- @param code string Error code ---- @param message string Error message ---- @return string formattedMessage Formatted error message with code -function ErrorCodes.formatMessage(code, message) - local info = ErrorCodes.get(code) - if info then - return string.format("[%s] %s", info.code, message) - end - return message -end - ---- Validate that all error codes are unique and properly formatted ---- @return boolean, string? Returns true if valid, or false with error message -function ErrorCodes.validate() - local seen = {} - local fullCodes = {} - - for code, info in pairs(ErrorCodes.codes) do - -- Check for duplicates - if seen[code] then - return false, "Duplicate error code: " .. code - end - seen[code] = true - - if fullCodes[info.code] then - return false, "Duplicate full error code: " .. info.code - end - fullCodes[info.code] = true - - -- Check format - if not code:match("^[A-Z]+_[0-9]+$") then - return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)" - end - - -- Check full code format - local expectedFullCode = "FLEXLOVE_" .. code - if info.code ~= expectedFullCode then - return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code - end - - -- Check required fields - if not info.description or info.description == "" then - return false, "Missing description for " .. code - end - if not info.suggestion or info.suggestion == "" then - return false, "Missing suggestion for " .. code - end - if not info.category or info.category == "" then - return false, "Missing category for " .. code - end - end - - return true, nil -end - -return ErrorCodes diff --git a/modules/ErrorHandler.lua b/modules/ErrorHandler.lua index d15ad0b..abeec71 100644 --- a/modules/ErrorHandler.lua +++ b/modules/ErrorHandler.lua @@ -1,7 +1,476 @@ -local ErrorHandler = {} -local ErrorCodes = nil -- Will be injected via init +---@class ErrorCodes +---@field categories table +---@field codes table +local ErrorCodes = { + categories = { + VAL = "Validation", + LAY = "Layout", + REN = "Render", + THM = "Theme", + EVT = "Event", + RES = "Resource", + SYS = "System", + }, + codes = { + -- Validation Errors (VAL_001 - VAL_099) + VAL_001 = { + code = "FLEXLOVE_VAL_001", + category = "VAL", + description = "Invalid property type", + suggestion = "Check the property type matches the expected type (e.g., number, string, table)", + }, + VAL_002 = { + code = "FLEXLOVE_VAL_002", + category = "VAL", + description = "Property value out of range", + suggestion = "Ensure the value is within the allowed min/max range", + }, + VAL_003 = { + code = "FLEXLOVE_VAL_003", + category = "VAL", + description = "Required property missing", + suggestion = "Provide the required property in your element definition", + }, + VAL_004 = { + code = "FLEXLOVE_VAL_004", + category = "VAL", + description = "Invalid color format", + suggestion = "Use valid color format: {r, g, b, a} with values 0-1, hex string, or Color object", + }, + VAL_005 = { + code = "FLEXLOVE_VAL_005", + category = "VAL", + description = "Invalid unit format", + suggestion = "Use valid unit format: number (px), '50%', '10vw', '5vh', etc.", + }, + VAL_006 = { + code = "FLEXLOVE_VAL_006", + category = "VAL", + description = "Invalid file path", + suggestion = "Check that the file path is correct and the file exists", + }, + VAL_007 = { + code = "FLEXLOVE_VAL_007", + category = "VAL", + description = "Invalid enum value", + suggestion = "Use one of the allowed enum values for this property", + }, + VAL_008 = { + code = "FLEXLOVE_VAL_008", + category = "VAL", + description = "Invalid text input", + suggestion = "Ensure text meets validation requirements (length, pattern, allowed characters)", + }, -local LOG_LEVELS = { + -- Layout Errors (LAY_001 - LAY_099) + LAY_001 = { + code = "FLEXLOVE_LAY_001", + category = "LAY", + description = "Invalid flex direction", + suggestion = "Use 'horizontal' or 'vertical' for flexDirection", + }, + LAY_002 = { + code = "FLEXLOVE_LAY_002", + category = "LAY", + description = "Circular dependency detected", + suggestion = "Remove circular references in element hierarchy or layout constraints", + }, + LAY_003 = { + code = "FLEXLOVE_LAY_003", + category = "LAY", + description = "Invalid dimensions (negative or NaN)", + suggestion = "Ensure width and height are positive numbers", + }, + LAY_004 = { + code = "FLEXLOVE_LAY_004", + category = "LAY", + description = "Layout calculation overflow", + suggestion = "Reduce complexity of layout or increase recursion limit", + }, + LAY_005 = { + code = "FLEXLOVE_LAY_005", + category = "LAY", + description = "Invalid alignment value", + suggestion = "Use valid alignment values (flex-start, center, flex-end, etc.)", + }, + LAY_006 = { + code = "FLEXLOVE_LAY_006", + category = "LAY", + description = "Invalid positioning mode", + suggestion = "Use 'absolute', 'relative', 'flex', or 'grid' for positioning", + }, + LAY_007 = { + code = "FLEXLOVE_LAY_007", + category = "LAY", + description = "Grid layout error", + suggestion = "Check grid template columns/rows and item placement", + }, + + -- Rendering Errors (REN_001 - REN_099) + REN_001 = { + code = "FLEXLOVE_REN_001", + category = "REN", + description = "Invalid render state", + suggestion = "Ensure element is properly initialized before rendering", + }, + REN_002 = { + code = "FLEXLOVE_REN_002", + category = "REN", + description = "Texture loading failed", + suggestion = "Check image path and format, ensure file exists", + }, + REN_003 = { + code = "FLEXLOVE_REN_003", + category = "REN", + description = "Font loading failed", + suggestion = "Check font path and format, ensure file exists", + }, + REN_004 = { + code = "FLEXLOVE_REN_004", + category = "REN", + description = "Invalid color value", + suggestion = "Color components must be numbers between 0 and 1", + }, + REN_005 = { + code = "FLEXLOVE_REN_005", + category = "REN", + description = "Clipping stack overflow", + suggestion = "Reduce nesting depth or check for missing scissor pops", + }, + REN_006 = { + code = "FLEXLOVE_REN_006", + category = "REN", + description = "Shader compilation failed", + suggestion = "Check shader code for syntax errors", + }, + REN_007 = { + code = "FLEXLOVE_REN_007", + category = "REN", + description = "Invalid nine-patch configuration", + suggestion = "Check nine-patch slice values and image dimensions", + }, + + -- Theme Errors (THM_001 - THM_099) + THM_001 = { + code = "FLEXLOVE_THM_001", + category = "THM", + description = "Theme file not found", + suggestion = "Check theme file path and ensure file exists", + }, + THM_002 = { + code = "FLEXLOVE_THM_002", + category = "THM", + description = "Invalid theme structure", + suggestion = "Theme must return a table with 'name' and component styles", + }, + THM_003 = { + code = "FLEXLOVE_THM_003", + category = "THM", + description = "Required theme property missing", + suggestion = "Ensure theme has required properties (name, base styles, etc.)", + }, + THM_004 = { + code = "FLEXLOVE_THM_004", + category = "THM", + description = "Invalid component style", + suggestion = "Component styles must be tables with valid properties", + }, + THM_005 = { + code = "FLEXLOVE_THM_005", + category = "THM", + description = "Theme loading failed", + suggestion = "Check theme file for Lua syntax errors", + }, + THM_006 = { + code = "FLEXLOVE_THM_006", + category = "THM", + description = "Invalid theme color", + suggestion = "Theme colors must be valid color values (hex, rgba, Color object)", + }, + + -- Event Errors (EVT_001 - EVT_099) + EVT_001 = { + code = "FLEXLOVE_EVT_001", + category = "EVT", + description = "Invalid event type", + suggestion = "Use valid event types (mousepressed, textinput, etc.)", + }, + EVT_002 = { + code = "FLEXLOVE_EVT_002", + category = "EVT", + description = "Event handler error", + suggestion = "Check event handler function for errors", + }, + EVT_003 = { + code = "FLEXLOVE_EVT_003", + category = "EVT", + description = "Event propagation error", + suggestion = "Check event bubbling/capturing logic", + }, + EVT_004 = { + code = "FLEXLOVE_EVT_004", + category = "EVT", + description = "Invalid event target", + suggestion = "Ensure event target element exists and is valid", + }, + EVT_005 = { + code = "FLEXLOVE_EVT_005", + category = "EVT", + description = "Event handler not a function", + suggestion = "Event handlers must be functions", + }, + + -- Resource Errors (RES_001 - RES_099) + RES_001 = { + code = "FLEXLOVE_RES_001", + category = "RES", + description = "File not found", + suggestion = "Check file path and ensure file exists in the filesystem", + }, + RES_002 = { + code = "FLEXLOVE_RES_002", + category = "RES", + description = "Permission denied", + suggestion = "Check file permissions and access rights", + }, + RES_003 = { + code = "FLEXLOVE_RES_003", + category = "RES", + description = "Invalid file format", + suggestion = "Ensure file format is supported (png, jpg, ttf, etc.)", + }, + RES_004 = { + code = "FLEXLOVE_RES_004", + category = "RES", + description = "Resource loading failed", + suggestion = "Check file integrity and format compatibility", + }, + RES_005 = { + code = "FLEXLOVE_RES_005", + category = "RES", + description = "Image cache error", + suggestion = "Clear image cache or check memory availability", + }, + + -- System Errors (SYS_001 - SYS_099) + SYS_001 = { + code = "FLEXLOVE_SYS_001", + category = "SYS", + description = "Memory allocation failed", + suggestion = "Reduce memory usage or check available memory", + }, + SYS_002 = { + code = "FLEXLOVE_SYS_002", + category = "SYS", + description = "Stack overflow", + suggestion = "Reduce recursion depth or check for infinite loops", + }, + SYS_003 = { + code = "FLEXLOVE_SYS_003", + category = "SYS", + description = "Invalid state", + suggestion = "Check initialization order and state management", + }, + SYS_004 = { + code = "FLEXLOVE_SYS_004", + category = "SYS", + description = "Module initialization failed", + suggestion = "Check module dependencies and initialization order", + }, + + -- Performance Warnings (PERF_001 - PERF_099) + PERF_001 = { + code = "FLEXLOVE_PERF_001", + category = "PERF", + description = "Performance threshold exceeded", + suggestion = "Operation took longer than recommended. Monitor for patterns.", + }, + PERF_002 = { + code = "FLEXLOVE_PERF_002", + category = "PERF", + description = "Critical performance threshold exceeded", + suggestion = "Operation is causing frame drops. Consider optimizing or reducing frequency.", + }, + + -- Memory Warnings (MEM_001 - MEM_099) + MEM_001 = { + code = "FLEXLOVE_MEM_001", + category = "MEM", + description = "Memory leak detected", + suggestion = "Table is growing consistently. Review cache eviction policies and ensure objects are properly released.", + }, + + -- State Management Warnings (STATE_001 - STATE_099) + STATE_001 = { + code = "FLEXLOVE_STATE_001", + category = "STATE", + description = "CallSite counters accumulating", + suggestion = "This indicates incrementFrame() may not be called properly. Check immediate mode frame management.", + }, + }, +} + +--- Get error information by code +--- @param code string Error code (e.g., "VAL_001" or "FLEXLOVE_VAL_001") +--- @return table? errorInfo Error information or nil if not found +function ErrorCodes.get(code) + -- Handle both short and full format + local shortCode = code:gsub("^FLEXLOVE_", "") + return ErrorCodes.codes[shortCode] +end + +--- Get human-readable description for error code +--- @param code string Error code +--- @return string description Error description +function ErrorCodes.describe(code) + local info = ErrorCodes.get(code) + if info then + return info.description + end + return "Unknown error code: " .. code +end + +--- Get suggested fix for error code +--- @param code string Error code +--- @return string suggestion Suggested fix +function ErrorCodes.getSuggestion(code) + local info = ErrorCodes.get(code) + if info then + return info.suggestion + end + return "No suggestion available" +end + +--- Get category for error code +--- @param code string Error code +--- @return string category Error category name +function ErrorCodes.getCategory(code) + local info = ErrorCodes.get(code) + if info then + return ErrorCodes.categories[info.category] or info.category + end + return "Unknown" +end + +--- List all error codes in a category +--- @param category string Category code (e.g., "VAL", "LAY") +--- @return table codes List of error codes in category +function ErrorCodes.listByCategory(category) + local result = {} + for code, info in pairs(ErrorCodes.codes) do + if info.category == category then + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + }) + end + end + table.sort(result, function(a, b) + return a.code < b.code + end) + return result +end + +--- Search error codes by keyword +--- @param keyword string Keyword to search for +--- @return table codes Matching error codes +function ErrorCodes.search(keyword) + keyword = keyword:lower() + local result = {} + for code, info in pairs(ErrorCodes.codes) do + local searchText = (code .. " " .. info.description .. " " .. info.suggestion):lower() + if searchText:find(keyword, 1, true) then + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + category = ErrorCodes.categories[info.category], + }) + end + end + return result +end + +--- Get all error codes +--- @return table codes All error codes +function ErrorCodes.listAll() + local result = {} + for code, info in pairs(ErrorCodes.codes) do + table.insert(result, { + code = code, + fullCode = info.code, + description = info.description, + suggestion = info.suggestion, + category = ErrorCodes.categories[info.category], + }) + end + table.sort(result, function(a, b) + return a.code < b.code + end) + return result +end + +--- Format error message with code +--- @param code string Error code +--- @param message string Error message +--- @return string formattedMessage Formatted error message with code +function ErrorCodes.formatMessage(code, message) + local info = ErrorCodes.get(code) + if info then + return string.format("[%s] %s", info.code, message) + end + return message +end + +--- Validate that all error codes are unique and properly formatted +--- @return boolean, string? Returns true if valid, or false with error message +function ErrorCodes.validate() + local seen = {} + local fullCodes = {} + + for code, info in pairs(ErrorCodes.codes) do + -- Check for duplicates + if seen[code] then + return false, "Duplicate error code: " .. code + end + seen[code] = true + + if fullCodes[info.code] then + return false, "Duplicate full error code: " .. info.code + end + fullCodes[info.code] = true + + -- Check format + if not code:match("^[A-Z]+_[0-9]+$") then + return false, "Invalid code format: " .. code .. " (expected CATEGORY_NUMBER)" + end + + -- Check full code format + local expectedFullCode = "FLEXLOVE_" .. code + if info.code ~= expectedFullCode then + return false, "Mismatched full code for " .. code .. ": expected " .. expectedFullCode .. ", got " .. info.code + end + + -- Check required fields + if not info.description or info.description == "" then + return false, "Missing description for " .. code + end + if not info.suggestion or info.suggestion == "" then + return false, "Missing suggestion for " .. code + end + if not info.category or info.category == "" then + return false, "Missing category for " .. code + end + end + + return true, nil +end + +---@enum LOG_LEVEL +local LOG_LEVEL = { CRITICAL = 1, ERROR = 2, WARNING = 3, @@ -9,124 +478,64 @@ local LOG_LEVELS = { DEBUG = 5, } -local config = { - debugMode = false, - includeStackTrace = false, - logLevel = LOG_LEVELS.WARNING, -- Default: log errors and warnings - logTarget = "console", -- Options: "console", "file", "both", "none" - logFormat = "human", -- Options: "human", "json" - logFile = "flexlove-errors.log", - maxLogSize = 10 * 1024 * 1024, -- 10MB default - maxLogFiles = 5, -- Keep 5 rotated logs - enableRotation = true, +---@enum LOG_TARGET +local LOG_TARGET = { + CONSOLE = "console", + FILE = "file", + BOTH = "both", + NONE = "none", } --- Internal state -local logFileHandle = nil -local currentLogSize = 0 +---@class ErrorHandler +---@field errorCodes ErrorCodes +---@field includeStackTrace boolean -- Default: false +---@field logLevel LOG_LEVEL --Default: LOG_LEVEL.WARNING +---@field logTarget "console" | "file" | "both" +---@field logFile string +---@field maxLogSize number in bytes +---@field maxLogFiles number files to rotate +---@field enableRotation boolean see maxLogFiles +---@field _currentLogSize number private +---@field _logFileHandle file* private +local ErrorHandler = { + errorCodes = ErrorCodes, +} +ErrorHandler.__index = ErrorHandler ---- Initialize ErrorHandler with dependencies ----@param deps table Dependencies table with ErrorCodes -function ErrorHandler.init(deps) - if deps and deps.ErrorCodes then - ErrorCodes = deps.ErrorCodes - else - -- Try to require if not provided (backward compatibility) - local success, module = pcall(require, "modules.ErrorCodes") - if success then - ErrorCodes = module - else - -- Create minimal stub if ErrorCodes not available - ErrorCodes = { - get = function() return nil end, - describe = function(code) return code end, - getSuggestion = function() return "" end, - } - end +---@type ErrorHandler|nil +local instance = nil + +---@param config { includeStackTrace?: boolean, logLevel?: LOG_LEVEL, logTarget?: "console" | "file" | "both", logFile?: string, maxLogSize?: number, maxLogFiles?: number, enableRotation?: boolean }|nil +---@return ErrorHandler +function ErrorHandler.init(config) + if instance == nil then + local self = setmetatable({}, ErrorHandler) + self.includeStackTrace = config and config.includeStackTrace or false + self.logLevel = config and config.logLevel or LOG_LEVEL.WARNING + self.logTarget = config and config.logTarget or LOG_TARGET.CONSOLE + self.logFile = config and config.logFile or "flexlove-errors.log" + self.maxLogSize = config and config.maxLogSize or 10 * 1024 * 1024 + self.maxLogFiles = config and config.maxLogFiles or 5 + self.enableRotation = config and config.enableRotation or true + self._currentLogSize = 0 + self._logFileHandle = nil + instance = self end + return instance end ---- Set debug mode (enables stack traces and verbose output) ----@param enabled boolean Enable debug mode -function ErrorHandler.setDebugMode(enabled) - config.debugMode = enabled - config.includeStackTrace = enabled - if enabled then - config.logLevel = LOG_LEVELS.DEBUG - end -end - ---- Set whether to include stack traces ----@param enabled boolean Enable stack traces -function ErrorHandler.setStackTrace(enabled) - config.includeStackTrace = enabled -end - ---- Set log level (minimum level to log) ----@param level string|number Log level ("CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG") or number -function ErrorHandler.setLogLevel(level) - if type(level) == "string" then - config.logLevel = LOG_LEVELS[level:upper()] or LOG_LEVELS.WARNING - elseif type(level) == "number" then - config.logLevel = level - end -end - ---- Set log target ----@param target string "console", "file", "both", or "none" -function ErrorHandler.setLogTarget(target) - config.logTarget = target - -- Note: File will be opened lazily on first write - if target == "console" or target == "none" then - -- Close log file if open - if logFileHandle then - logFileHandle:close() - logFileHandle = nil - currentLogSize = 0 - end - end -end - ---- Set log format ----@param format string "human" or "json" -function ErrorHandler.setLogFormat(format) - config.logFormat = format -end - ---- Set log file path ----@param path string Path to log file -function ErrorHandler.setLogFile(path) - -- Close existing log file - if logFileHandle then - logFileHandle:close() - logFileHandle = nil - end - - config.logFile = path - currentLogSize = 0 - - -- Note: File will be opened lazily on first write -end - ---- Enable/disable log rotation ----@param enabled boolean|table Enable rotation or config table -function ErrorHandler.enableLogRotation(enabled) - if type(enabled) == "boolean" then - config.enableRotation = enabled - elseif type(enabled) == "table" then - config.enableRotation = true - if enabled.maxSize then - config.maxLogSize = enabled.maxSize - end - if enabled.maxFiles then - config.maxLogFiles = enabled.maxFiles - end +--- Get the singleton instance (lazily initializes if needed) +---@return ErrorHandler +function ErrorHandler.getInstance() + if instance == nil then + ErrorHandler.init() end + return instance end --- Get current timestamp with milliseconds ----@return string Formatted timestamp -local function getTimestamp() +---@return string|osdate Formatted timestamp +function ErrorHandler:_getTimestamp() local time = os.time() local date = os.date("%Y-%m-%d %H:%M:%S", time) -- Note: Lua doesn't have millisecond precision by default, so we approximate @@ -134,39 +543,39 @@ local function getTimestamp() end --- Rotate log file if needed -local function rotateLogIfNeeded() - if not config.enableRotation then +function ErrorHandler:_rotateLogIfNeeded() + if not self.enableRotation then return end - if currentLogSize < config.maxLogSize then + if self._currentLogSize < self.maxLogSize then return end -- Close current log - if logFileHandle then - logFileHandle:close() - logFileHandle = nil + if self._logFileHandle then + self._logFileHandle:close() + self._logFileHandle = nil end -- Rotate existing logs - for i = config.maxLogFiles - 1, 1, -1 do - local oldName = config.logFile .. "." .. i - local newName = config.logFile .. "." .. (i + 1) + for i = self.maxLogFiles - 1, 1, -1 do + local oldName = self.logFile .. "." .. i + local newName = self.logFile .. "." .. (i + 1) os.rename(oldName, newName) -- Will fail silently if file doesn't exist end -- Move current log to .1 - os.rename(config.logFile, config.logFile .. ".1") + os.rename(self.logFile, self.logFile .. ".1") -- Create new log file - logFileHandle = io.open(config.logFile, "a") - currentLogSize = 0 + self._logFileHandle = io.open(self.logFile, "a") + self._currentLogSize = 0 end --- Escape string for JSON ---@param str string String to escape ---@return string Escaped string -local function escapeJson(str) +function ErrorHandler:_escapeJson(str) str = tostring(str) str = str:gsub("\\", "\\\\") str = str:gsub('"', '\\"') @@ -179,15 +588,15 @@ end --- Format details as JSON object ---@param details table|nil Details object ---@return string JSON string -local function formatDetailsJson(details) +function ErrorHandler:_formatDetailsJson(details) if not details or type(details) ~= "table" then return "{}" end local parts = {} for key, value in pairs(details) do - local jsonKey = escapeJson(tostring(key)) - local jsonValue = escapeJson(tostring(value)) + local jsonKey = self:_escapeJson(tostring(key)) + local jsonValue = self:_escapeJson(tostring(value)) table.insert(parts, string.format('"%s":"%s"', jsonKey, jsonValue)) end @@ -197,7 +606,7 @@ end --- Format details object as readable key-value pairs ---@param details table|nil Details object ---@return string Formatted details -local function formatDetails(details) +function ErrorHandler:_formatDetails(details) if not details or type(details) ~= "table" then return "" end @@ -222,8 +631,8 @@ end --- Extract and format stack trace ---@param level number Stack level to start from ---@return string Formatted stack trace -local function formatStackTrace(level) - if not config.includeStackTrace then +function ErrorHandler:_formatStackTrace(level) + if not self.includeStackTrace then return "" end @@ -263,7 +672,7 @@ end ---@param detailsOrSuggestion table|string|nil Details or suggestion ---@param suggestionOrNil string|nil Suggestion ---@return string Formatted message -local function formatMessage(module, level, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestionOrNil) +function ErrorHandler:_formatMessage(module, level, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestionOrNil) local code = nil local message = codeOrMessage local details = nil @@ -307,7 +716,7 @@ local function formatMessage(module, level, codeOrMessage, messageOrDetails, det -- Details section if details then - table.insert(parts, formatDetails(details)) + table.insert(parts, self:_formatDetails(details)) end -- Suggestion section @@ -326,91 +735,65 @@ end ---@param message string Message ---@param details table|nil Details ---@param suggestion string|nil Suggestion -local function writeLog(level, levelNum, module, code, message, details, suggestion) +function ErrorHandler:_writeLog(level, levelNum, module, code, message, details, suggestion) -- Check if we should log this level - if levelNum > config.logLevel then + if levelNum > self.logLevel then return end - local timestamp = getTimestamp() + local timestamp = self:_getTimestamp() local logEntry - if config.logFormat == "json" then - -- JSON format - local jsonParts = { - string.format('"timestamp":"%s"', escapeJson(timestamp)), - string.format('"level":"%s"', level), - string.format('"module":"%s"', escapeJson(module)), - string.format('"message":"%s"', escapeJson(message)), - } + local jsonParts = { + string.format('"timestamp":"%s"', self:_escapeJson(timestamp)), + string.format('"level":"%s"', level), + string.format('"module":"%s"', self:_escapeJson(module)), + string.format('"message":"%s"', self:_escapeJson(message)), + } - if code then - table.insert(jsonParts, string.format('"code":"%s"', escapeJson(code))) - end - - if details then - table.insert(jsonParts, string.format('"details":%s', formatDetailsJson(details))) - end - - if suggestion then - table.insert(jsonParts, string.format('"suggestion":"%s"', escapeJson(suggestion))) - end - - logEntry = "{" .. table.concat(jsonParts, ",") .. "}\n" - else - -- Human-readable format - local parts = { - string.format("[%s] [%s] [%s]", timestamp, level, module), - } - - if code then - table.insert(parts, string.format("[%s]", code)) - end - - table.insert(parts, message) - logEntry = table.concat(parts, " ") .. "\n" - - if details then - logEntry = logEntry .. formatDetails(details):gsub("^\n\n", "") .. "\n" - end - - if suggestion then - logEntry = logEntry .. "Suggestion: " .. suggestion .. "\n" - end - - logEntry = logEntry .. "\n" + if code then + table.insert(jsonParts, string.format('"code":"%s"', self:_escapeJson(code))) end - -- Write to console - if config.logTarget == "console" or config.logTarget == "both" then + if details then + table.insert(jsonParts, string.format('"details":%s', self:_formatDetailsJson(details))) + end + + if suggestion then + table.insert(jsonParts, string.format('"suggestion":"%s"', self:_escapeJson(suggestion))) + end + + logEntry = "{" .. table.concat(jsonParts, ",") .. "}\n" + + if self.logTarget == "console" or self.logTarget == "both" then io.write(logEntry) io.flush() end -- Write to file - if config.logTarget == "file" or config.logTarget == "both" then + if self.logTarget == "file" or self.logTarget == "both" then -- Lazy file opening: open on first write - if not logFileHandle then - logFileHandle = io.open(config.logFile, "a") - if logFileHandle then + if not self._logFileHandle then + self._logFileHandle = io.open(self.logFile, "a") + if self._logFileHandle then -- Get current file size - local currentPos = logFileHandle:seek("end") - currentLogSize = currentPos or 0 + local currentPos = self._logFileHandle:seek("end") + self._currentLogSize = currentPos or 0 end end - if logFileHandle then - rotateLogIfNeeded() + if self._logFileHandle then + self:_rotateLogIfNeeded() -- Reopen if rotation closed it - if not logFileHandle then - logFileHandle = io.open(config.logFile, "a") + if not self._logFileHandle then + self._logFileHandle = io.open(self.logFile, "a") end - if logFileHandle then - logFileHandle:write(logEntry) - logFileHandle:flush() - currentLogSize = currentLogSize + #logEntry + if self._logFileHandle then + self._logFileHandle:write(logEntry) + self._logFileHandle:flush() + self._currentLogSize = self._currentLogSize + #logEntry end end end @@ -422,8 +805,8 @@ end ---@param messageOrDetails string|table|nil Message or details ---@param detailsOrSuggestion table|string|nil Details or suggestion ---@param suggestion string|nil Suggestion -function ErrorHandler.error(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) - local formattedMessage = formatMessage(module, "Error", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) +function ErrorHandler:error(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) + local formattedMessage = self:_formatMessage(module, "Error", codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) -- Parse arguments for logging local code = nil @@ -454,11 +837,10 @@ function ErrorHandler.error(module, codeOrMessage, messageOrDetails, detailsOrSu end -- Log the error - writeLog("ERROR", LOG_LEVELS.ERROR, module, code, message, details, logSuggestion) + self:_writeLog("ERROR", LOG_LEVEL.ERROR, module, code, message, details, logSuggestion) - -- Add stack trace if enabled - if config.includeStackTrace then - formattedMessage = formattedMessage .. formatStackTrace(3) + if self.includeStackTrace then + formattedMessage = formattedMessage .. self:_formatStackTrace(3) end error(formattedMessage, 2) @@ -470,7 +852,7 @@ end ---@param messageOrDetails string|table|nil Message or details ---@param detailsOrSuggestion table|string|nil Details or suggestion ---@param suggestion string|nil Suggestion -function ErrorHandler.warn(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) +function ErrorHandler:warn(module, codeOrMessage, messageOrDetails, detailsOrSuggestion, suggestion) -- Parse arguments for logging local code = nil local message = codeOrMessage @@ -499,8 +881,8 @@ function ErrorHandler.warn(module, codeOrMessage, messageOrDetails, detailsOrSug end end - -- Log the warning (writeLog handles console output based on config.logTarget) - writeLog("WARNING", LOG_LEVELS.WARNING, module, code, message, details, logSuggestion) + -- Log the warning + self:_writeLog("WARNING", LOG_LEVEL.WARNING, module, code, message, details, logSuggestion) end --- Validate that a value is not nil @@ -508,9 +890,9 @@ end ---@param value any The value to check ---@param paramName string The parameter name ---@return boolean True if valid -function ErrorHandler.assertNotNil(module, value, paramName) +function ErrorHandler:assertNotNil(module, value, paramName) if value == nil then - ErrorHandler.error(module, "VAL_003", "Required parameter missing", { + self:error(module, "VAL_003", "Required parameter missing", { parameter = paramName, }) return false @@ -524,10 +906,10 @@ end ---@param expectedType string The expected type name ---@param paramName string The parameter name ---@return boolean True if valid -function ErrorHandler.assertType(module, value, expectedType, paramName) +function ErrorHandler:assertType(module, value, expectedType, paramName) local actualType = type(value) if actualType ~= expectedType then - ErrorHandler.error(module, "VAL_001", "Invalid property type", { + self:error(module, "VAL_001", "Invalid property type", { property = paramName, expected = expectedType, got = actualType, @@ -544,9 +926,9 @@ end ---@param max number Maximum value (inclusive) ---@param paramName string The parameter name ---@return boolean True if valid -function ErrorHandler.assertRange(module, value, min, max, paramName) +function ErrorHandler:assertRange(module, value, min, max, paramName) if value < min or value > max then - ErrorHandler.error(module, "VAL_002", "Property value out of range", { + self:error(module, "VAL_002", "Property value out of range", { property = paramName, min = tostring(min), max = tostring(max), @@ -561,16 +943,16 @@ end ---@param module string The module name ---@param oldName string The deprecated name ---@param newName string The new name to use -function ErrorHandler.warnDeprecated(module, oldName, newName) - ErrorHandler.warn(module, string.format("'%s' is deprecated. Use '%s' instead", oldName, newName)) +function ErrorHandler:warnDeprecated(module, oldName, newName) + self:warn(module, string.format("'%s' is deprecated. Use '%s' instead", oldName, newName)) end --- Warn about a common mistake ---@param module string The module name ---@param issue string Description of the issue ---@param suggestion string Suggested fix -function ErrorHandler.warnCommonMistake(module, issue, suggestion) - ErrorHandler.warn(module, string.format("%s. Suggestion: %s", issue, suggestion)) +function ErrorHandler:warnCommonMistake(module, issue, suggestion) + self:warn(module, string.format("%s. Suggestion: %s", issue, suggestion)) end return ErrorHandler diff --git a/modules/Transform.lua b/modules/Transform.lua deleted file mode 100644 index c44120e..0000000 --- a/modules/Transform.lua +++ /dev/null @@ -1,154 +0,0 @@ ---- Transform module for 2D transformations (rotate, scale, translate, skew) ----@class Transform ----@field rotate number? Rotation in radians (default: 0) ----@field scaleX number? X-axis scale (default: 1) ----@field scaleY number? Y-axis scale (default: 1) ----@field translateX number? X translation in pixels (default: 0) ----@field translateY number? Y translation in pixels (default: 0) ----@field skewX number? X-axis skew in radians (default: 0) ----@field skewY number? Y-axis skew in radians (default: 0) ----@field originX number? Transform origin X (0-1, default: 0.5) ----@field originY number? Transform origin Y (0-1, default: 0.5) -local Transform = {} -Transform.__index = Transform - ---- Create a new transform instance ----@param props TransformProps? ----@return Transform transform -function Transform.new(props) - props = props or {} - - local self = setmetatable({}, Transform) - - self.rotate = props.rotate or 0 - self.scaleX = props.scaleX or 1 - self.scaleY = props.scaleY or 1 - self.translateX = props.translateX or 0 - self.translateY = props.translateY or 0 - self.skewX = props.skewX or 0 - self.skewY = props.skewY or 0 - self.originX = props.originX or 0.5 - self.originY = props.originY or 0.5 - - return self -end - ---- Apply transform to LÖVE graphics context ----@param transform Transform Transform instance ----@param x number Element x position ----@param y number Element y position ----@param width number Element width ----@param height number Element height -function Transform.apply(transform, x, y, width, height) - if not transform then - return - end - - -- Calculate transform origin - local ox = x + width * transform.originX - local oy = y + height * transform.originY - - -- Apply transform in correct order: translate → rotate → scale → skew - love.graphics.push() - love.graphics.translate(ox, oy) - - if transform.rotate ~= 0 then - love.graphics.rotate(transform.rotate) - end - - if transform.scaleX ~= 1 or transform.scaleY ~= 1 then - love.graphics.scale(transform.scaleX, transform.scaleY) - end - - if transform.skewX ~= 0 or transform.skewY ~= 0 then - love.graphics.shear(transform.skewX, transform.skewY) - end - - love.graphics.translate(-ox, -oy) - love.graphics.translate(transform.translateX, transform.translateY) -end - ---- Remove transform from LÖVE graphics context -function Transform.unapply() - love.graphics.pop() -end - ---- Interpolate between two transforms ----@param from Transform Starting transform ----@param to Transform Ending transform ----@param t number Interpolation factor (0-1) ----@return Transform interpolated -function Transform.lerp(from, to, t) - -- Sanitize inputs - if type(from) ~= "table" then - from = Transform.new() - end - if type(to) ~= "table" then - to = Transform.new() - end - if type(t) ~= "number" or t ~= t then - -- NaN or invalid type - t = 0 - elseif t == math.huge then - -- Positive infinity - t = 1 - elseif t == -math.huge then - -- Negative infinity - t = 0 - else - -- Clamp t to 0-1 range - t = math.max(0, math.min(1, t)) - end - - return Transform.new({ - rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t, - scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t, - scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t, - translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t, - translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t, - skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t, - skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t, - originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t, - originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t, - }) -end - ---- Check if transform is identity (no transformation) ----@param transform Transform ----@return boolean isIdentity -function Transform.isIdentity(transform) - if not transform then - return true - end - - return transform.rotate == 0 - and transform.scaleX == 1 - and transform.scaleY == 1 - and transform.translateX == 0 - and transform.translateY == 0 - and transform.skewX == 0 - and transform.skewY == 0 -end - ---- Clone a transform ----@param transform Transform ----@return Transform clone -function Transform.clone(transform) - if not transform then - return Transform.new() - end - - return Transform.new({ - rotate = transform.rotate, - scaleX = transform.scaleX, - scaleY = transform.scaleY, - translateX = transform.translateX, - translateY = transform.translateY, - skewX = transform.skewX, - skewY = transform.skewY, - originX = transform.originX, - originY = transform.originY, - }) -end - -return Transform diff --git a/testing/__tests__/color_validation_test.lua b/testing/__tests__/color_validation_test.lua index ad560f7..5383fbe 100644 --- a/testing/__tests__/color_validation_test.lua +++ b/testing/__tests__/color_validation_test.lua @@ -7,6 +7,10 @@ require("testing.loveStub") -- Import the Color module local Color = require("modules.Color") +local ErrorHandler = require("modules.ErrorHandler") +local ErrorCodes = require("modules.ErrorCodes") +ErrorHandler.init({ ErrorCodes }) +Color.init({ ErrorHandler }) -- Test Suite for Color Validation TestColorValidation = {} diff --git a/testing/__tests__/easing_test.lua b/testing/__tests__/easing_test.lua index fe90b84..9bc55cb 100644 --- a/testing/__tests__/easing_test.lua +++ b/testing/__tests__/easing_test.lua @@ -20,27 +20,47 @@ function TestEasing:testAllEasingFunctionsExist() -- Linear "linear", -- Quad - "easeInQuad", "easeOutQuad", "easeInOutQuad", + "easeInQuad", + "easeOutQuad", + "easeInOutQuad", -- Cubic - "easeInCubic", "easeOutCubic", "easeInOutCubic", + "easeInCubic", + "easeOutCubic", + "easeInOutCubic", -- Quart - "easeInQuart", "easeOutQuart", "easeInOutQuart", + "easeInQuart", + "easeOutQuart", + "easeInOutQuart", -- Quint - "easeInQuint", "easeOutQuint", "easeInOutQuint", + "easeInQuint", + "easeOutQuint", + "easeInOutQuint", -- Expo - "easeInExpo", "easeOutExpo", "easeInOutExpo", + "easeInExpo", + "easeOutExpo", + "easeInOutExpo", -- Sine - "easeInSine", "easeOutSine", "easeInOutSine", + "easeInSine", + "easeOutSine", + "easeInOutSine", -- Circ - "easeInCirc", "easeOutCirc", "easeInOutCirc", + "easeInCirc", + "easeOutCirc", + "easeInOutCirc", -- Back - "easeInBack", "easeOutBack", "easeInOutBack", + "easeInBack", + "easeOutBack", + "easeInOutBack", -- Elastic - "easeInElastic", "easeOutElastic", "easeInOutElastic", + "easeInElastic", + "easeOutElastic", + "easeInOutElastic", -- Bounce - "easeInBounce", "easeOutBounce", "easeInOutBounce", + "easeInBounce", + "easeOutBounce", + "easeInOutBounce", } - + for _, name in ipairs(easings) do luaunit.assertNotNil(Easing[name], "Easing function " .. name .. " should exist") luaunit.assertEquals(type(Easing[name]), "function", name .. " should be a function") @@ -239,7 +259,7 @@ function TestEasing:testList() local list = Easing.list() luaunit.assertEquals(type(list), "table") luaunit.assertEquals(#list, 31, "Should have exactly 31 easing functions") - + -- Check that linear is in the list local hasLinear = false for _, name in ipairs(list) do @@ -257,7 +277,7 @@ function TestEasing:testGet() luaunit.assertNotNil(linear) luaunit.assertEquals(type(linear), "function") luaunit.assertEquals(linear(0.5), 0.5) - + -- Test non-existent easing local nonExistent = Easing.get("nonExistentEasing") luaunit.assertNil(nonExistent) @@ -266,11 +286,18 @@ end -- Test that all InOut easings are symmetric around 0.5 function TestEasing:testInOutSymmetry() local inOutEasings = { - "easeInOutQuad", "easeInOutCubic", "easeInOutQuart", "easeInOutQuint", - "easeInOutExpo", "easeInOutSine", "easeInOutCirc", "easeInOutBack", - "easeInOutElastic", "easeInOutBounce" + "easeInOutQuad", + "easeInOutCubic", + "easeInOutQuart", + "easeInOutQuint", + "easeInOutExpo", + "easeInOutSine", + "easeInOutCirc", + "easeInOutBack", + "easeInOutElastic", + "easeInOutBounce", } - + for _, name in ipairs(inOutEasings) do local easing = Easing[name] -- At t=0.5, all InOut easings should be close to 0.5 @@ -283,28 +310,50 @@ end function TestEasing:testBoundaryConditions() local easings = { "linear", - "easeInQuad", "easeOutQuad", "easeInOutQuad", - "easeInCubic", "easeOutCubic", "easeInOutCubic", - "easeInQuart", "easeOutQuart", "easeInOutQuart", - "easeInQuint", "easeOutQuint", "easeInOutQuint", - "easeInExpo", "easeOutExpo", "easeInOutExpo", - "easeInSine", "easeOutSine", "easeInOutSine", - "easeInCirc", "easeOutCirc", "easeInOutCirc", - "easeInBack", "easeOutBack", "easeInOutBack", - "easeInElastic", "easeOutElastic", "easeInOutElastic", - "easeInBounce", "easeOutBounce", "easeInOutBounce", + "easeInQuad", + "easeOutQuad", + "easeInOutQuad", + "easeInCubic", + "easeOutCubic", + "easeInOutCubic", + "easeInQuart", + "easeOutQuart", + "easeInOutQuart", + "easeInQuint", + "easeOutQuint", + "easeInOutQuint", + "easeInExpo", + "easeOutExpo", + "easeInOutExpo", + "easeInSine", + "easeOutSine", + "easeInOutSine", + "easeInCirc", + "easeOutCirc", + "easeInOutCirc", + "easeInBack", + "easeOutBack", + "easeInOutBack", + "easeInElastic", + "easeOutElastic", + "easeInOutElastic", + "easeInBounce", + "easeOutBounce", + "easeInOutBounce", } - + for _, name in ipairs(easings) do local easing = Easing[name] -- All easings should start at 0 local start = easing(0) luaunit.assertAlmostEquals(start, 0, 0.01, name .. " should start at 0") - + -- All easings should end at 1 local finish = easing(1) luaunit.assertAlmostEquals(finish, 1, 0.01, name .. " should end at 1") end end -os.exit(luaunit.LuaUnit.run()) +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/event_handler_test.lua b/testing/__tests__/event_handler_test.lua index b2fad0d..14567de 100644 --- a/testing/__tests__/event_handler_test.lua +++ b/testing/__tests__/event_handler_test.lua @@ -524,7 +524,7 @@ function TestEventHandler:test_onEventDeferred() local MockFlexLove = { deferCallback = function(callback) table.insert(deferredCallbacks, callback) - end + end, } package.loaded["FlexLove"] = MockFlexLove @@ -533,7 +533,7 @@ function TestEventHandler:test_onEventDeferred() onEventDeferred = true, onEvent = function(el, event) table.insert(eventsReceived, event) - end + end, }) local element = createMockElement() handler:initialize(element) @@ -545,12 +545,14 @@ function TestEventHandler:test_onEventDeferred() -- Press and release mouse button handler:processMouseEvents(50, 50, true, true) - love.mouse.isDown = function() return false end + love.mouse.isDown = function() + return false + end handler:processMouseEvents(50, 50, true, true) -- Events should not be immediately executed luaunit.assertEquals(#eventsReceived, 0) - + -- Should have deferred callbacks queued luaunit.assertTrue(#deferredCallbacks > 0) @@ -561,7 +563,7 @@ function TestEventHandler:test_onEventDeferred() -- Now events should be received luaunit.assertTrue(#eventsReceived > 0) - + -- Check that we got a click event local hasClick = false for _, event in ipairs(eventsReceived) do @@ -583,7 +585,7 @@ function TestEventHandler:test_onEventDeferred_false() onEventDeferred = false, onEvent = function(el, event) table.insert(eventsReceived, event) - end + end, }) local element = createMockElement() handler:initialize(element) @@ -595,12 +597,14 @@ function TestEventHandler:test_onEventDeferred_false() -- Press and release mouse button handler:processMouseEvents(50, 50, true, true) - love.mouse.isDown = function() return false end + love.mouse.isDown = function() + return false + end handler:processMouseEvents(50, 50, true, true) -- Events should be immediately executed luaunit.assertTrue(#eventsReceived > 0) - + -- Check that we got a click event local hasClick = false for _, event in ipairs(eventsReceived) do diff --git a/testing/__tests__/flexlove_test.lua b/testing/__tests__/flexlove_test.lua index 380ae4a..45421e7 100644 --- a/testing/__tests__/flexlove_test.lua +++ b/testing/__tests__/flexlove_test.lua @@ -642,19 +642,19 @@ end -- Test: deferCallback() queues callback function TestFlexLove:testDeferCallbackQueuesCallback() FlexLove.setMode("retained") - + local called = false FlexLove.deferCallback(function() called = true end) - + -- Callback should not be called immediately luaunit.assertFalse(called) - + -- Callback should be called after executeDeferredCallbacks FlexLove.draw() luaunit.assertFalse(called) -- Still not called - + FlexLove.executeDeferredCallbacks() luaunit.assertTrue(called) -- Now called end @@ -662,7 +662,7 @@ end -- Test: deferCallback() with multiple callbacks function TestFlexLove:testDeferCallbackMultiple() FlexLove.setMode("retained") - + local order = {} FlexLove.deferCallback(function() table.insert(order, 1) @@ -673,10 +673,10 @@ function TestFlexLove:testDeferCallbackMultiple() FlexLove.deferCallback(function() table.insert(order, 3) end) - + FlexLove.draw() FlexLove.executeDeferredCallbacks() - + luaunit.assertEquals(#order, 3) luaunit.assertEquals(order[1], 1) luaunit.assertEquals(order[2], 2) @@ -686,12 +686,12 @@ end -- Test: deferCallback() with non-function argument function TestFlexLove:testDeferCallbackInvalidArgument() FlexLove.setMode("retained") - + -- Should warn but not crash FlexLove.deferCallback("not a function") FlexLove.deferCallback(123) FlexLove.deferCallback(nil) - + FlexLove.draw() luaunit.assertTrue(true) end @@ -699,16 +699,16 @@ end -- Test: deferCallback() clears queue after execution function TestFlexLove:testDeferCallbackClearsQueue() FlexLove.setMode("retained") - + local callCount = 0 FlexLove.deferCallback(function() callCount = callCount + 1 end) - + FlexLove.draw() FlexLove.executeDeferredCallbacks() -- First execution luaunit.assertEquals(callCount, 1) - + FlexLove.draw() FlexLove.executeDeferredCallbacks() -- Second execution should not call again luaunit.assertEquals(callCount, 1) @@ -717,7 +717,7 @@ end -- Test: deferCallback() handles callback errors gracefully function TestFlexLove:testDeferCallbackWithError() FlexLove.setMode("retained") - + local called = false FlexLove.deferCallback(function() error("Intentional error") @@ -725,7 +725,7 @@ function TestFlexLove:testDeferCallbackWithError() FlexLove.deferCallback(function() called = true end) - + -- Should not crash, second callback should still execute FlexLove.draw() FlexLove.executeDeferredCallbacks() @@ -1329,4 +1329,6 @@ function TestFlexLoveUnhappyPaths:testImmediateModeFrameEdgeCases() luaunit.assertTrue(true) end -return TestFlexLove +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/font_cache_test.lua b/testing/__tests__/font_cache_test.lua index cf9ee29..eab0750 100644 --- a/testing/__tests__/font_cache_test.lua +++ b/testing/__tests__/font_cache_test.lua @@ -29,13 +29,13 @@ function TestFontCache:testCacheHitOnRepeatedAccess() local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.misses, 1) luaunit.assertEquals(stats1.hits, 0) - + -- Second access should be a hit utils.FONT_CACHE.get(16, nil) local stats2 = utils.getFontCacheStats() luaunit.assertEquals(stats2.hits, 1) luaunit.assertEquals(stats2.misses, 1) - + -- Third access should also be a hit utils.FONT_CACHE.get(16, nil) local stats3 = utils.getFontCacheStats() @@ -46,17 +46,17 @@ end function TestFontCache:testCacheMissOnFirstAccess() utils.clearFontCache() utils.resetFontCacheStats() - + utils.FONT_CACHE.get(24, nil) local stats = utils.getFontCacheStats() - + luaunit.assertEquals(stats.misses, 1) luaunit.assertEquals(stats.hits, 0) end function TestFontCache:testLRUEviction() utils.setFontCacheSize(3) - + -- Load 3 fonts (fills cache) with time steps to ensure different timestamps utils.FONT_CACHE.get(10, nil) love.timer.step(0.001) @@ -64,34 +64,34 @@ function TestFontCache:testLRUEviction() love.timer.step(0.001) utils.FONT_CACHE.get(14, nil) love.timer.step(0.001) - + local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.size, 3) luaunit.assertEquals(stats1.evictions, 0) - + -- Load 4th font (triggers eviction of font 10 - the oldest) utils.FONT_CACHE.get(16, nil) - + local stats2 = utils.getFontCacheStats() luaunit.assertEquals(stats2.size, 3) luaunit.assertEquals(stats2.evictions, 1) - + -- Access first font again - it should have been evicted (miss) local initialMisses = stats2.misses utils.FONT_CACHE.get(10, nil) - + local stats3 = utils.getFontCacheStats() luaunit.assertEquals(stats3.misses, initialMisses + 1) -- Should be a miss end function TestFontCache:testCacheSizeLimitEnforced() utils.setFontCacheSize(5) - + -- Load 10 fonts for i = 1, 10 do utils.FONT_CACHE.get(10 + i, nil) end - + local stats = utils.getFontCacheStats() luaunit.assertEquals(stats.size, 5) luaunit.assertTrue(stats.evictions >= 5) @@ -102,7 +102,7 @@ function TestFontCache:testFontRounding() utils.FONT_CACHE.get(14.5, nil) local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.misses, 1) - + utils.FONT_CACHE.get(14.7, nil) local stats2 = utils.getFontCacheStats() luaunit.assertEquals(stats2.hits, 1) -- Should be a hit because both round to 15 @@ -112,12 +112,12 @@ end function TestFontCache:testCacheClear() utils.FONT_CACHE.get(16, nil) utils.FONT_CACHE.get(18, nil) - + local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.size, 2) - + utils.clearFontCache() - + local stats2 = utils.getFontCacheStats() luaunit.assertEquals(stats2.size, 0) end @@ -126,7 +126,7 @@ function TestFontCache:testCacheKeyWithPath() -- Different cache keys for same size, different paths utils.FONT_CACHE.get(16, nil) utils.FONT_CACHE.get(16, "fonts/custom.ttf") - + local stats = utils.getFontCacheStats() luaunit.assertEquals(stats.misses, 2) -- Both should be misses luaunit.assertEquals(stats.size, 2) @@ -135,14 +135,14 @@ end function TestFontCache:testPreloadFont() utils.clearFontCache() utils.resetFontCacheStats() - + -- Preload multiple sizes - utils.preloadFont(nil, {12, 14, 16, 18}) - + utils.preloadFont(nil, { 12, 14, 16, 18 }) + local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.size, 4) luaunit.assertEquals(stats1.misses, 4) -- All preloads are misses - + -- Now access one - should be a hit utils.FONT_CACHE.get(16, nil) local stats2 = utils.getFontCacheStats() @@ -152,31 +152,31 @@ end function TestFontCache:testCacheHitRate() utils.clearFontCache() utils.resetFontCacheStats() - + -- 1 miss, 9 hits = 90% hit rate utils.FONT_CACHE.get(16, nil) for i = 1, 9 do utils.FONT_CACHE.get(16, nil) end - + local stats = utils.getFontCacheStats() luaunit.assertEquals(stats.hitRate, 0.9) end function TestFontCache:testSetCacheSizeEvictsExcess() utils.setFontCacheSize(10) - + -- Load 10 fonts for i = 1, 10 do utils.FONT_CACHE.get(10 + i, nil) end - + local stats1 = utils.getFontCacheStats() luaunit.assertEquals(stats1.size, 10) - + -- Reduce cache size - should trigger evictions utils.setFontCacheSize(5) - + local stats2 = utils.getFontCacheStats() luaunit.assertEquals(stats2.size, 5) luaunit.assertTrue(stats2.evictions >= 5) @@ -186,14 +186,11 @@ function TestFontCache:testMinimalCacheSize() -- Minimum cache size is 1 utils.setFontCacheSize(0) utils.FONT_CACHE.get(16, nil) - + local stats = utils.getFontCacheStats() luaunit.assertEquals(stats.size, 1) end --- Run tests if executed directly -if arg and arg[0]:find("font_cache_test%.lua$") then +if not _G.RUNNING_ALL_TESTS then os.exit(luaunit.LuaUnit.run()) end - -return TestFontCache diff --git a/testing/__tests__/keyframe_animation_test.lua b/testing/__tests__/keyframe_animation_test.lua index edc02b2..99cc696 100644 --- a/testing/__tests__/keyframe_animation_test.lua +++ b/testing/__tests__/keyframe_animation_test.lua @@ -21,11 +21,11 @@ function TestKeyframeAnimation:testCreateKeyframeAnimation() local anim = Animation.keyframes({ duration = 2, keyframes = { - {at = 0, values = {x = 0, opacity = 0}}, - {at = 1, values = {x = 100, opacity = 1}}, - } + { at = 0, values = { x = 0, opacity = 0 } }, + { at = 1, values = { x = 100, opacity = 1 } }, + }, }) - + luaunit.assertNotNil(anim) luaunit.assertEquals(type(anim), "table") luaunit.assertEquals(anim.duration, 2) @@ -38,13 +38,13 @@ function TestKeyframeAnimation:testMultipleWaypoints() local anim = Animation.keyframes({ duration = 3, keyframes = { - {at = 0, values = {x = 0, opacity = 0}}, - {at = 0.25, values = {x = 50, opacity = 1}}, - {at = 0.75, values = {x = 150, opacity = 1}}, - {at = 1, values = {x = 200, opacity = 0}}, - } + { at = 0, values = { x = 0, opacity = 0 } }, + { at = 0.25, values = { x = 50, opacity = 1 } }, + { at = 0.75, values = { x = 150, opacity = 1 } }, + { at = 1, values = { x = 200, opacity = 0 } }, + }, }) - + luaunit.assertEquals(#anim.keyframes, 4) luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[2].at, 0.25) @@ -57,12 +57,12 @@ function TestKeyframeAnimation:testKeyframeSorting() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 1, values = {x = 100}}, - {at = 0, values = {x = 0}}, - {at = 0.5, values = {x = 50}}, - } + { at = 1, values = { x = 100 } }, + { at = 0, values = { x = 0 } }, + { at = 0.5, values = { x = 50 } }, + }, }) - + -- Should be sorted by 'at' position luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[2].at, 0.5) @@ -74,14 +74,14 @@ function TestKeyframeAnimation:testInterpolationAtStart() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0, opacity = 0}}, - {at = 1, values = {x = 100, opacity = 1}}, - } + { at = 0, values = { x = 0, opacity = 0 } }, + { at = 1, values = { x = 100, opacity = 1 } }, + }, }) - + anim.elapsed = 0 local result = anim:interpolate() - + luaunit.assertNotNil(result.x) luaunit.assertNotNil(result.opacity) luaunit.assertAlmostEquals(result.x, 0, 0.01) @@ -93,14 +93,14 @@ function TestKeyframeAnimation:testInterpolationAtEnd() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0, opacity = 0}}, - {at = 1, values = {x = 100, opacity = 1}}, - } + { at = 0, values = { x = 0, opacity = 0 } }, + { at = 1, values = { x = 100, opacity = 1 } }, + }, }) - + anim.elapsed = 1 local result = anim:interpolate() - + luaunit.assertAlmostEquals(result.x, 100, 0.01) luaunit.assertAlmostEquals(result.opacity, 1, 0.01) end @@ -110,14 +110,14 @@ function TestKeyframeAnimation:testInterpolationAtMidpoint() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}}, - {at = 1, values = {x = 100}}, - } + { at = 0, values = { x = 0 } }, + { at = 1, values = { x = 100 } }, + }, }) - + anim.elapsed = 0.5 local result = anim:interpolate() - + luaunit.assertAlmostEquals(result.x, 50, 0.01) end @@ -126,22 +126,22 @@ function TestKeyframeAnimation:testPerKeyframeEasing() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}, easing = "easeInQuad"}, - {at = 0.5, values = {x = 50}, easing = "linear"}, - {at = 1, values = {x = 100}}, - } + { at = 0, values = { x = 0 }, easing = "easeInQuad" }, + { at = 0.5, values = { x = 50 }, easing = "linear" }, + { at = 1, values = { x = 100 } }, + }, }) - + -- At t=0.25 (middle of first segment with easeInQuad) anim.elapsed = 0.25 - anim._resultDirty = true -- Mark dirty to force recalculation + anim._resultDirty = true -- Mark dirty to force recalculation local result1 = anim:interpolate() -- easeInQuad at 0.5 should give 0.25, so x = 0 + (50-0) * 0.25 = 12.5 luaunit.assertTrue(result1.x < 25, "easeInQuad should slow start") - + -- At t=0.75 (middle of second segment with linear) anim.elapsed = 0.75 - anim._resultDirty = true -- Mark dirty to force recalculation + anim._resultDirty = true -- Mark dirty to force recalculation local result2 = anim:interpolate() -- linear at 0.5 should give 0.5, so x = 50 + (100-50) * 0.5 = 75 luaunit.assertAlmostEquals(result2.x, 75, 1) @@ -152,22 +152,22 @@ function TestKeyframeAnimation:testFindKeyframes() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}}, - {at = 0.25, values = {x = 25}}, - {at = 0.75, values = {x = 75}}, - {at = 1, values = {x = 100}}, - } + { at = 0, values = { x = 0 } }, + { at = 0.25, values = { x = 25 } }, + { at = 0.75, values = { x = 75 } }, + { at = 1, values = { x = 100 } }, + }, }) - + -- Test finding keyframes at different progress values local prev1, next1 = anim:findKeyframes(0.1) luaunit.assertEquals(prev1.at, 0) luaunit.assertEquals(next1.at, 0.25) - + local prev2, next2 = anim:findKeyframes(0.5) luaunit.assertEquals(prev2.at, 0.25) luaunit.assertEquals(next2.at, 0.75) - + local prev3, next3 = anim:findKeyframes(0.9) luaunit.assertEquals(prev3.at, 0.75) luaunit.assertEquals(next3.at, 1) @@ -178,18 +178,18 @@ function TestKeyframeAnimation:testKeyframeAnimationUpdate() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {opacity = 0}}, - {at = 1, values = {opacity = 1}}, - } + { at = 0, values = { opacity = 0 } }, + { at = 1, values = { opacity = 1 } }, + }, }) - + -- Update halfway through anim:update(0.5) local result = anim:interpolate() - + luaunit.assertAlmostEquals(result.opacity, 0.5, 0.01) luaunit.assertFalse(anim:update(0)) -- Not complete yet - + -- Update to completion luaunit.assertTrue(anim:update(0.6)) -- Should complete luaunit.assertEquals(anim:getState(), "completed") @@ -200,23 +200,29 @@ function TestKeyframeAnimation:testKeyframeAnimationCallbacks() local startCalled = false local updateCalled = false local completeCalled = false - + local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}}, - {at = 1, values = {x = 100}}, + { at = 0, values = { x = 0 } }, + { at = 1, values = { x = 100 } }, }, - onStart = function() startCalled = true end, - onUpdate = function() updateCalled = true end, - onComplete = function() completeCalled = true end, + onStart = function() + startCalled = true + end, + onUpdate = function() + updateCalled = true + end, + onComplete = function() + completeCalled = true + end, }) - + anim:update(0.5) luaunit.assertTrue(startCalled) luaunit.assertTrue(updateCalled) luaunit.assertFalse(completeCalled) - + anim:update(0.6) luaunit.assertTrue(completeCalled) end @@ -226,9 +232,9 @@ function TestKeyframeAnimation:testMissingKeyframes() -- Should create default keyframes with warning local anim = Animation.keyframes({ duration = 1, - keyframes = {} + keyframes = {}, }) - + luaunit.assertNotNil(anim) luaunit.assertEquals(#anim.keyframes, 2) -- Should have default start and end end @@ -239,10 +245,10 @@ function TestKeyframeAnimation:testSingleKeyframe() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0.5, values = {x = 50}} - } + { at = 0.5, values = { x = 50 } }, + }, }) - + luaunit.assertNotNil(anim) luaunit.assertTrue(#anim.keyframes >= 2) -- Should have at least 2 keyframes end @@ -252,11 +258,11 @@ function TestKeyframeAnimation:testKeyframesWithoutStart() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0.5, values = {x = 50}}, - {at = 1, values = {x = 100}}, - } + { at = 0.5, values = { x = 50 } }, + { at = 1, values = { x = 100 } }, + }, }) - + -- Should auto-add keyframe at 0 luaunit.assertEquals(anim.keyframes[1].at, 0) luaunit.assertEquals(anim.keyframes[1].values.x, 50) -- Should copy first keyframe values @@ -267,11 +273,11 @@ function TestKeyframeAnimation:testKeyframesWithoutEnd() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}}, - {at = 0.5, values = {x = 50}}, - } + { at = 0, values = { x = 0 } }, + { at = 0.5, values = { x = 50 } }, + }, }) - + -- Should auto-add keyframe at 1 luaunit.assertEquals(anim.keyframes[#anim.keyframes].at, 1) luaunit.assertEquals(anim.keyframes[#anim.keyframes].values.x, 50) -- Should copy last keyframe values @@ -282,9 +288,9 @@ function TestKeyframeAnimation:testInvalidKeyframeProps() -- Should handle gracefully with warnings local anim = Animation.keyframes({ duration = 0, -- Invalid - keyframes = "not a table" -- Invalid + keyframes = "not a table", -- Invalid }) - + luaunit.assertNotNil(anim) luaunit.assertEquals(anim.duration, 1) -- Should use default end @@ -294,22 +300,22 @@ function TestKeyframeAnimation:testMultiPropertyKeyframes() local anim = Animation.keyframes({ duration = 2, keyframes = { - {at = 0, values = {x = 0, y = 0, opacity = 0, width = 50}}, - {at = 0.33, values = {x = 100, y = 50, opacity = 1, width = 100}}, - {at = 0.66, values = {x = 200, y = 100, opacity = 1, width = 150}}, - {at = 1, values = {x = 300, y = 150, opacity = 0, width = 200}}, - } + { at = 0, values = { x = 0, y = 0, opacity = 0, width = 50 } }, + { at = 0.33, values = { x = 100, y = 50, opacity = 1, width = 100 } }, + { at = 0.66, values = { x = 200, y = 100, opacity = 1, width = 150 } }, + { at = 1, values = { x = 300, y = 150, opacity = 0, width = 200 } }, + }, }) - + -- Test interpolation at 0.5 (middle of second segment) anim.elapsed = 1.0 -- t = 0.5 local result = anim:interpolate() - + luaunit.assertNotNil(result.x) luaunit.assertNotNil(result.y) luaunit.assertNotNil(result.opacity) luaunit.assertNotNil(result.width) - + -- Should be interpolating between keyframes at 0.33 and 0.66 luaunit.assertTrue(result.x > 100 and result.x < 200) luaunit.assertTrue(result.y > 50 and result.y < 100) @@ -317,19 +323,21 @@ end -- Test keyframe with easing function (not string) function TestKeyframeAnimation:testKeyframeWithEasingFunction() - local customEasing = function(t) return t * t end - + local customEasing = function(t) + return t * t + end + local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}, easing = customEasing}, - {at = 1, values = {x = 100}}, - } + { at = 0, values = { x = 0 }, easing = customEasing }, + { at = 1, values = { x = 100 } }, + }, }) - + anim.elapsed = 0.5 local result = anim:interpolate() - + -- At t=0.5, easing(0.5) = 0.25, so x = 0 + 100 * 0.25 = 25 luaunit.assertAlmostEquals(result.x, 25, 1) end @@ -339,16 +347,18 @@ function TestKeyframeAnimation:testKeyframeCaching() local anim = Animation.keyframes({ duration = 1, keyframes = { - {at = 0, values = {x = 0}}, - {at = 1, values = {x = 100}}, - } + { at = 0, values = { x = 0 } }, + { at = 1, values = { x = 100 } }, + }, }) - + anim.elapsed = 0.5 local result1 = anim:interpolate() local result2 = anim:interpolate() -- Should return cached result - + luaunit.assertEquals(result1, result2) -- Should be same table end -os.exit(luaunit.LuaUnit.run()) +if not _G.RUNNING_ALL_TESTS then + os.exit(luaunit.LuaUnit.run()) +end diff --git a/testing/__tests__/touch_events_test.lua b/testing/__tests__/touch_events_test.lua index 28f555f..2d7632f 100644 --- a/testing/__tests__/touch_events_test.lua +++ b/testing/__tests__/touch_events_test.lua @@ -11,10 +11,10 @@ TestTouchEvents = {} -- Test: InputEvent.fromTouch creates valid touch event function TestTouchEvents:testInputEvent_FromTouch() local InputEvent = package.loaded["modules.InputEvent"] - + local touchId = "touch1" local event = InputEvent.fromTouch(touchId, 100, 200, "began", 0.8) - + lu.assertEquals(event.type, "touchpress") lu.assertEquals(event.x, 100) lu.assertEquals(event.y, 200) @@ -27,9 +27,9 @@ end -- Test: Touch event with moved phase function TestTouchEvents:testInputEvent_FromTouch_Moved() local InputEvent = package.loaded["modules.InputEvent"] - + local event = InputEvent.fromTouch("touch1", 150, 250, "moved", 1.0) - + lu.assertEquals(event.type, "touchmove") lu.assertEquals(event.phase, "moved") end @@ -37,9 +37,9 @@ end -- Test: Touch event with ended phase function TestTouchEvents:testInputEvent_FromTouch_Ended() local InputEvent = package.loaded["modules.InputEvent"] - + local event = InputEvent.fromTouch("touch1", 150, 250, "ended", 1.0) - + lu.assertEquals(event.type, "touchrelease") lu.assertEquals(event.phase, "ended") end @@ -47,9 +47,9 @@ end -- Test: Touch event with cancelled phase function TestTouchEvents:testInputEvent_FromTouch_Cancelled() local InputEvent = package.loaded["modules.InputEvent"] - + local event = InputEvent.fromTouch("touch1", 150, 250, "cancelled", 1.0) - + lu.assertEquals(event.type, "touchcancel") lu.assertEquals(event.phase, "cancelled") end @@ -57,7 +57,7 @@ end -- Test: EventHandler tracks touch began function TestTouchEvents:testEventHandler_TouchBegan() FlexLove.beginFrame() - + local touchEvents = {} local element = FlexLove.new({ width = 200, @@ -66,21 +66,25 @@ function TestTouchEvents:testEventHandler_TouchBegan() table.insert(touchEvents, event) end, }) - + FlexLove.endFrame() - + -- Simulate touch began - love.touch.getTouches = function() return {"touch1"} end + love.touch.getTouches = function() + return { "touch1" } + end love.touch.getPosition = function(id) - if id == "touch1" then return 100, 100 end + if id == "touch1" then + return 100, 100 + end return 0, 0 end - + -- Trigger touch event processing FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- Should have received a touchpress event lu.assertEquals(#touchEvents, 1) lu.assertEquals(touchEvents[1].type, "touchpress") @@ -90,7 +94,7 @@ end -- Test: EventHandler tracks touch moved function TestTouchEvents:testEventHandler_TouchMoved() FlexLove.beginFrame() - + local touchEvents = {} local element = FlexLove.new({ width = 200, @@ -99,31 +103,37 @@ function TestTouchEvents:testEventHandler_TouchMoved() table.insert(touchEvents, event) end, }) - + FlexLove.endFrame() - + -- Simulate touch began - love.touch.getTouches = function() return {"touch1"} end + love.touch.getTouches = function() + return { "touch1" } + end love.touch.getPosition = function(id) - if id == "touch1" then return 100, 100 end + if id == "touch1" then + return 100, 100 + end return 0, 0 end - + -- First touch FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- Move touch love.touch.getPosition = function(id) - if id == "touch1" then return 150, 150 end + if id == "touch1" then + return 150, 150 + end return 0, 0 end - + FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- Should have received touchpress and touchmove events lu.assertEquals(#touchEvents, 2) lu.assertEquals(touchEvents[1].type, "touchpress") @@ -135,7 +145,7 @@ end -- Test: EventHandler tracks touch ended function TestTouchEvents:testEventHandler_TouchEnded() FlexLove.beginFrame() - + local touchEvents = {} local element = FlexLove.new({ width = 200, @@ -144,28 +154,34 @@ function TestTouchEvents:testEventHandler_TouchEnded() table.insert(touchEvents, event) end, }) - + FlexLove.endFrame() - + -- Simulate touch began - love.touch.getTouches = function() return {"touch1"} end + love.touch.getTouches = function() + return { "touch1" } + end love.touch.getPosition = function(id) - if id == "touch1" then return 100, 100 end + if id == "touch1" then + return 100, 100 + end return 0, 0 end - + -- First touch FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- End touch - love.touch.getTouches = function() return {} end - + love.touch.getTouches = function() + return {} + end + FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- Should have received touchpress and touchrelease events lu.assertEquals(#touchEvents, 2) lu.assertEquals(touchEvents[1].type, "touchpress") @@ -175,7 +191,7 @@ end -- Test: EventHandler tracks multiple simultaneous touches function TestTouchEvents:testEventHandler_MultiTouch() FlexLove.beginFrame() - + local touchEvents = {} local element = FlexLove.new({ width = 200, @@ -184,26 +200,32 @@ function TestTouchEvents:testEventHandler_MultiTouch() table.insert(touchEvents, event) end, }) - + FlexLove.endFrame() - + -- Simulate two touches - love.touch.getTouches = function() return {"touch1", "touch2"} end + love.touch.getTouches = function() + return { "touch1", "touch2" } + end love.touch.getPosition = function(id) - if id == "touch1" then return 50, 50 end - if id == "touch2" then return 150, 150 end + if id == "touch1" then + return 50, 50 + end + if id == "touch2" then + return 150, 150 + end return 0, 0 end - + FlexLove.beginFrame() element._eventHandler:processTouchEvents() FlexLove.endFrame() - + -- Should have received two touchpress events lu.assertEquals(#touchEvents, 2) lu.assertEquals(touchEvents[1].type, "touchpress") lu.assertEquals(touchEvents[2].type, "touchpress") - + -- Different touch IDs lu.assertNotEquals(touchEvents[1].touchId, touchEvents[2].touchId) end @@ -213,24 +235,26 @@ function TestTouchEvents:testGestureRecognizer_Tap() local GestureRecognizer = package.loaded["modules.GestureRecognizer"] local InputEvent = package.loaded["modules.InputEvent"] local utils = package.loaded["modules.utils"] - + local recognizer = GestureRecognizer.new({}, { InputEvent = InputEvent, utils = utils, }) - + -- Simulate tap (press and quick release) local touchId = "touch1" local pressEvent = InputEvent.fromTouch(touchId, 100, 100, "began", 1.0) local releaseEvent = InputEvent.fromTouch(touchId, 102, 102, "ended", 1.0) - + recognizer:processTouchEvent(pressEvent) local gesture = recognizer:processTouchEvent(releaseEvent) - - -- Note: The gesture detection returns from internal methods, + + -- Note: The gesture detection returns from internal methods, -- needs to be captured from the event processing -- This is a basic structural test lu.assertNotNil(recognizer) end -os.exit(lu.LuaUnit.run()) +if not _G.RUNNING_ALL_TESTS then + os.exit(lu.LuaUnit.run()) +end diff --git a/testing/runAll.lua b/testing/runAll.lua index 40845fd..9826b17 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -19,29 +19,37 @@ local luaunit = require("testing.luaunit") local testFiles = { "testing/__tests__/animation_test.lua", "testing/__tests__/animation_properties_test.lua", - "testing/__tests__/transform_test.lua", "testing/__tests__/blur_test.lua", "testing/__tests__/color_validation_test.lua", + "testing/__tests__/critical_failures_test.lua", + "testing/__tests__/easing_test.lua", "testing/__tests__/element_test.lua", "testing/__tests__/error_handler_test.lua", "testing/__tests__/event_handler_test.lua", "testing/__tests__/flexlove_test.lua", + "testing/__tests__/font_cache_test.lua", "testing/__tests__/grid_test.lua", "testing/__tests__/image_cache_test.lua", "testing/__tests__/image_renderer_test.lua", "testing/__tests__/image_scaler_test.lua", + "testing/__tests__/image_tiling_test.lua", "testing/__tests__/input_event_test.lua", + "testing/__tests__/keyframe_animation_test.lua", "testing/__tests__/layout_edge_cases_test.lua", "testing/__tests__/layout_engine_test.lua", "testing/__tests__/ninepatch_parser_test.lua", "testing/__tests__/ninepatch_test.lua", "testing/__tests__/overflow_test.lua", "testing/__tests__/path_validation_test.lua", + "testing/__tests__/performance_instrumentation_test.lua", + "testing/__tests__/performance_warnings_test.lua", "testing/__tests__/renderer_test.lua", "testing/__tests__/roundedrect_test.lua", "testing/__tests__/sanitization_test.lua", "testing/__tests__/text_editor_test.lua", "testing/__tests__/theme_test.lua", + "testing/__tests__/touch_events_test.lua", + "testing/__tests__/transform_test.lua", "testing/__tests__/units_test.lua", "testing/__tests__/utils_test.lua", }