starting refactor for sanity

This commit is contained in:
Michael Freno
2025-11-19 12:14:58 -05:00
parent d5796bc677
commit b24af17179
17 changed files with 1927 additions and 2045 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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