better docs/error reporting

This commit is contained in:
Michael Freno
2025-11-17 09:28:41 -05:00
parent d7ace1d535
commit a8be1f5342
5 changed files with 313 additions and 149 deletions

View File

@@ -1,4 +1,8 @@
--- Easing function type
---@alias EasingFunction fun(t: number): number
--- Easing functions for animations
---@type table<string, EasingFunction>
local Easing = {
linear = function(t)
return t
@@ -40,19 +44,48 @@ local Easing = {
return t == 1 and 1 or 1 - math.pow(2, -10 * t)
end,
}
---@class AnimationProps
---@field duration number Duration in seconds
---@field start {width?:number, height?:number, opacity?:number} Starting values
---@field final {width?:number, height?:number, opacity?:number} Final values
---@field easing string? Easing function name (default: "linear")
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@class Animation
---@field duration number
---@field start {width?:number, height?:number, opacity?:number}
---@field final {width?:number, height?:number, opacity?:number}
---@field elapsed number
---@field transform table?
---@field transition table?
---@field duration number Duration in seconds
---@field start {width?:number, height?:number, opacity?:number} Starting values
---@field final {width?:number, height?:number, opacity?:number} Final values
---@field elapsed number Elapsed time in seconds
---@field easing EasingFunction Easing function
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@field _cachedResult table Cached interpolation result
---@field _resultDirty boolean Whether cached result needs recalculation
local Animation = {}
Animation.__index = Animation
---@param props AnimationProps
---@return Animation
---Create a new animation instance
---@param props AnimationProps Animation properties
---@return Animation animation The new animation instance
function Animation.new(props)
-- Validate input
if type(props) ~= "table" then
error("[FlexLove.Animation] Animation.new() requires a table argument")
end
if type(props.duration) ~= "number" or props.duration <= 0 then
error("[FlexLove.Animation] Animation duration must be a positive number")
end
if type(props.start) ~= "table" then
error("[FlexLove.Animation] Animation start must be a table")
end
if type(props.final) ~= "table" then
error("[FlexLove.Animation] Animation final must be a table")
end
local self = setmetatable({}, Animation)
self.duration = props.duration
self.start = props.start
@@ -61,8 +94,15 @@ function Animation.new(props)
self.transition = props.transition
self.elapsed = 0
-- Validate and set easing function
local easingName = props.easing or "linear"
self.easing = Easing[easingName] or Easing.linear
if type(easingName) == "string" then
self.easing = Easing[easingName] or Easing.linear
elseif type(easingName) == "function" then
self.easing = easingName
else
self.easing = Easing.linear
end
-- Pre-allocate result table to avoid GC pressure
self._cachedResult = {}
@@ -71,9 +111,15 @@ function Animation.new(props)
return self
end
---@param dt number
---@return boolean
---Update the animation with delta time
---@param dt number Delta time in seconds
---@return boolean completed True if animation is complete
function Animation:update(dt)
-- Sanitize dt
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
dt = 0
end
self.elapsed = self.elapsed + dt
self._resultDirty = true
if self.elapsed >= self.duration then
@@ -83,7 +129,8 @@ function Animation:update(dt)
end
end
---@return table
---Interpolate animation values at current time
---@return table result Interpolated values {width?, height?, opacity?, ...}
function Animation:interpolate()
-- Return cached result if not dirty (avoids recalculation)
if not self._resultDirty then
@@ -91,26 +138,36 @@ function Animation:interpolate()
end
local t = math.min(self.elapsed / self.duration, 1)
t = self.easing(t)
-- Apply easing function with protection
local success, easedT = pcall(self.easing, t)
if not success or type(easedT) ~= "number" or easedT ~= easedT or easedT == math.huge or easedT == -math.huge then
easedT = t -- Fallback to linear if easing fails
end
local result = self._cachedResult -- Reuse existing table
result.width = nil
result.height = nil
result.opacity = nil
if self.start.width and self.final.width then
result.width = self.start.width * (1 - t) + self.final.width * t
-- Interpolate width if both start and final are valid numbers
if type(self.start.width) == "number" and type(self.final.width) == "number" then
result.width = self.start.width * (1 - easedT) + self.final.width * easedT
end
if self.start.height and self.final.height then
result.height = self.start.height * (1 - t) + self.final.height * t
-- Interpolate height if both start and final are valid numbers
if type(self.start.height) == "number" and type(self.final.height) == "number" then
result.height = self.start.height * (1 - easedT) + self.final.height * easedT
end
if self.start.opacity and self.final.opacity then
result.opacity = self.start.opacity * (1 - t) + self.final.opacity * t
-- Interpolate opacity if both start and final are valid numbers
if type(self.start.opacity) == "number" and type(self.final.opacity) == "number" then
result.opacity = self.start.opacity * (1 - easedT) + self.final.opacity * easedT
end
if self.transform then
-- Copy transform properties
if self.transform and type(self.transform) == "table" then
for key, value in pairs(self.transform) do
result[key] = value
end
@@ -120,36 +177,66 @@ function Animation:interpolate()
return result
end
---@param element Element
---Apply this animation to an element
---@param element Element The element to apply animation to
function Animation:apply(element)
if not element or type(element) ~= "table" then
error("[FlexLove.Animation] Cannot apply animation to nil or non-table element")
end
element.animation = self
end
--- Create a simple fade animation
---@param duration number
---@param fromOpacity number
---@param toOpacity number
---@return Animation
function Animation.fade(duration, fromOpacity, toOpacity)
---@param duration number Duration in seconds
---@param fromOpacity number Starting opacity (0-1)
---@param toOpacity number Ending opacity (0-1)
---@param easing string? Easing function name (default: "linear")
---@return Animation animation The fade animation
function Animation.fade(duration, fromOpacity, toOpacity, easing)
-- Sanitize inputs
if type(duration) ~= "number" or duration <= 0 then
duration = 1
end
if type(fromOpacity) ~= "number" then
fromOpacity = 1
end
if type(toOpacity) ~= "number" then
toOpacity = 0
end
return Animation.new({
duration = duration,
start = { opacity = fromOpacity },
final = { opacity = toOpacity },
easing = easing,
transform = {},
transition = {},
})
end
--- Create a simple scale animation
---@param duration number
---@param fromScale table{width:number,height:number}
---@param toScale table{width:number,height:number}
---@return Animation
function Animation.scale(duration, fromScale, toScale)
---@param duration number Duration in seconds
---@param fromScale {width:number,height:number} Starting scale
---@param toScale {width:number,height:number} Ending scale
---@param easing string? Easing function name (default: "linear")
---@return Animation animation The scale animation
function Animation.scale(duration, fromScale, toScale, easing)
-- Sanitize inputs
if type(duration) ~= "number" or duration <= 0 then
duration = 1
end
if type(fromScale) ~= "table" then
fromScale = { width = 1, height = 1 }
end
if type(toScale) ~= "table" then
toScale = { width = 1, height = 1 }
end
return Animation.new({
duration = duration,
start = { width = fromScale.width, height = fromScale.height },
final = { width = toScale.width, height = toScale.height },
start = { width = fromScale.width or 0, height = fromScale.height or 0 },
final = { width = toScale.width or 0, height = toScale.height or 0 },
easing = easing,
transform = {},
transition = {},
})

View File

@@ -54,31 +54,53 @@ local Color = {}
Color.__index = Color
--- Create a new color instance
---@param r number?
---@param g number?
---@param b number?
---@param a number?
---@return Color
---@param r number? Red component (0-1), defaults to 0
---@param g number? Green component (0-1), defaults to 0
---@param b number? Blue component (0-1), defaults to 0
---@param a number? Alpha component (0-1), defaults to 1
---@return Color color The new color instance
function Color.new(r, g, b, a)
local self = setmetatable({}, Color)
self.r = r or 0
self.g = g or 0
self.b = b or 0
self.a = a or 1
-- 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
self.a = sanitizedA or 1
return self
end
---@return number r, number g, number b, number a
---Convert color to RGBA components
---@return number r Red component (0-1)
---@return number g Green component (0-1)
---@return number b Blue component (0-1)
---@return number a Alpha component (0-1)
function Color:toRGBA()
return self.r, self.g, self.b, self.a
end
--- Convert hex string to color
--- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats
---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA"
---@return Color
---@throws Error if hex string format is invalid
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
---@return Color color The parsed color (returns white on error with warning)
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
if ErrorHandler then
ErrorHandler.warn("Color", "VAL_004", "Invalid color format", {
input = tostring(hexWithTag),
issue = "not a string",
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))
@@ -125,10 +147,10 @@ function Color.fromHex(hexWithTag)
end
--- Validate a single color channel value
---@param value any -- Value to validate
---@param max number? -- Maximum value (255 for 0-255 range, 1 for 0-1 range)
---@return boolean valid -- True if valid
---@return number? clamped -- Clamped value in 0-1 range
---@param value any Value to validate
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
---@return boolean valid True if valid
---@return number? clamped Clamped value in 0-1 range, nil if invalid
function Color.validateColorChannel(value, max)
max = max or 1
@@ -159,9 +181,9 @@ function Color.validateColorChannel(value, max)
end
--- Validate hex color format
---@param hex string -- Hex color string (with or without #)
---@return boolean valid -- True if valid format
---@return string? error -- Error message if invalid
---@param hex string Hex color string (with or without #)
---@return boolean valid True if valid format
---@return string? error Error message if invalid, nil if valid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
@@ -184,13 +206,13 @@ function Color.validateHexColor(hex)
end
--- Validate RGB/RGBA color values
---@param r number -- Red component
---@param g number -- Green component
---@param b number -- Blue component
---@param a number? -- Alpha component (optional)
---@param max number? -- Maximum value (255 or 1)
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
---@param r number Red component
---@param g number Green component
---@param b number Blue component
---@param a number? Alpha component (optional, defaults to max)
---@param max number? Maximum value (255 or 1), defaults to 1
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
@@ -217,9 +239,9 @@ function Color.validateRGBColor(r, g, b, a, max)
end
--- Validate named color
---@param name string -- Color name
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
---@param name string Color name (e.g. "red", "blue", "transparent")
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateNamedColor(name)
if type(name) ~= "string" then
return false, "Color name must be a string"
@@ -234,8 +256,8 @@ function Color.validateNamedColor(name)
end
--- Check if a value is a valid color format
---@param value any -- Value to check
---@return string? format -- Format type (hex, rgb, rgba, named, table, nil if invalid)
---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid
function Color.isValidColorFormat(value)
local valueType = type(value)
@@ -286,10 +308,10 @@ function Color.isValidColorFormat(value)
end
--- Validate a color value
---@param value any -- Color value to validate
---@param options table? -- Validation options
---@return boolean valid -- True if valid
---@return string? error -- Error message if invalid
---@param value any Color value to validate
---@param options table? Validation options {allowNamed: boolean, requireAlpha: boolean}
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateColor(value, options)
options = options or {}
local allowNamed = options.allowNamed ~= false
@@ -320,10 +342,10 @@ function Color.validateColor(value, options)
return true, nil
end
--- Sanitize a color value
---@param value any -- Color value to sanitize
---@param default Color? -- Default color if invalid
---@return Color -- Sanitized color
--- Sanitize a color value (always returns a valid Color)
---@param value any Color value to sanitize (hex, named, table, or Color instance)
---@param default Color? Default color if invalid (defaults to black)
---@return Color color Sanitized color instance (guaranteed non-nil)
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
@@ -396,9 +418,9 @@ function Color.sanitizeColor(value, default)
return default
end
--- Parse a color from various formats
---@param value any -- Color value (hex, named, table)
---@return Color -- Parsed color
--- Parse a color from various formats (always returns a valid Color)
---@param value any Color value (hex string, named color, table, or Color instance)
---@return Color color Parsed color instance (defaults to black on error)
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end

View File

@@ -124,7 +124,17 @@ Theme.__index = Theme
local themes = {}
local activeTheme = nil
---Create a new theme instance
---@param definition ThemeDefinition Theme definition table
---@return Theme theme The new theme instance
function Theme.new(definition)
-- Validate input type first
if type(definition) ~= "table" then
ErrorHandler.error("Theme", "THM_001", "Invalid theme definition", {
error = "Theme definition must be a table, got " .. type(definition)
})
end
-- Validate theme definition
local valid, err = validateThemeDefinition(definition)
if not valid then
@@ -303,8 +313,8 @@ function Theme.new(definition)
end
--- Load a theme from a Lua file
---@param path string -- Path to theme definition file (e.g., "space" or "mytheme")
---@return Theme
---@param path string Path to theme definition file (e.g., "space" or "mytheme")
---@return Theme? theme The loaded theme, or nil on error
function Theme.load(path)
local definition
local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path
@@ -338,7 +348,8 @@ function Theme.load(path)
return theme
end
---@param themeOrName Theme|string
---Set the active theme
---@param themeOrName Theme|string Theme instance or theme name to activate
function Theme.setActive(themeOrName)
if type(themeOrName) == "string" then
-- Try to load if not already loaded
@@ -361,15 +372,15 @@ function Theme.setActive(themeOrName)
end
--- Get the active theme
---@return Theme?
---@return Theme? theme The active theme, or nil if none is active
function Theme.getActive()
return activeTheme
end
--- Get a component from the active theme
---@param componentName string -- Name of the component (e.g., "button", "panel")
---@param state string? -- Optional state (e.g., "hover", "pressed", "disabled")
---@return ThemeComponent? -- Returns component or nil if not found
---@param componentName string Name of the component (e.g., "button", "panel")
---@param state string? Optional state (e.g., "hover", "pressed", "disabled")
---@return ThemeComponent? component Returns component or nil if not found
function Theme.getComponent(componentName, state)
if not activeTheme then
return nil
@@ -389,8 +400,8 @@ function Theme.getComponent(componentName, state)
end
--- Get a font from the active theme
---@param fontName string -- Name of the font family (e.g., "default", "heading")
---@return string? -- Returns font path or nil if not found
---@param fontName string Name of the font family (e.g., "default", "heading")
---@return string? fontPath Returns font path or nil if not found
function Theme.getFont(fontName)
if not activeTheme then
return nil
@@ -400,8 +411,8 @@ function Theme.getFont(fontName)
end
--- Get a color from the active theme
---@param colorName string -- Name of the color (e.g., "primary", "secondary")
---@return Color? -- Returns Color instance or nil if not found
---@param colorName string Name of the color (e.g., "primary", "secondary")
---@return Color? color Returns Color instance or nil if not found
function Theme.getColor(colorName)
if not activeTheme then
return nil
@@ -411,13 +422,13 @@ function Theme.getColor(colorName)
end
--- Check if a theme is currently active
---@return boolean -- Returns true if a theme is active
---@return boolean active Returns true if a theme is active
function Theme.hasActive()
return activeTheme ~= nil
end
--- Get all registered theme names
---@return table<string> -- Array of theme names
---@return string[] themeNames Array of theme names
function Theme.getRegisteredThemes()
local themeNames = {}
for name, _ in pairs(themes) do
@@ -427,7 +438,7 @@ function Theme.getRegisteredThemes()
end
--- Get all available color names from the active theme
---@return table<string>|nil -- Array of color names, or nil if no theme active
---@return string[]? colorNames Array of color names, or nil if no theme active
function Theme.getColorNames()
if not activeTheme or not activeTheme.colors then
return nil
@@ -441,7 +452,7 @@ function Theme.getColorNames()
end
--- Get all colors from the active theme
---@return table<string, Color>|nil -- Table of all colors, or nil if no theme active
---@return table<string, Color>? colors Table of all colors, or nil if no theme active
function Theme.getAllColors()
if not activeTheme then
return nil
@@ -451,9 +462,9 @@ function Theme.getAllColors()
end
--- Get a color with a fallback if not found
---@param colorName string -- Name of the color to retrieve
---@param fallback Color|nil -- Fallback color if not found (default: white)
---@return Color -- The color or fallback
---@param colorName string Name of the color to retrieve
---@param fallback Color? Fallback color if not found (default: white)
---@return Color color The color or fallback (guaranteed non-nil)
function Theme.getColorOrDefault(colorName, fallback)
local color = Theme.getColor(colorName)
if color then
@@ -464,8 +475,8 @@ function Theme.getColorOrDefault(colorName, fallback)
end
--- Get a theme by name
---@param themeName string -- Name of the theme
---@return Theme|nil -- Returns theme or nil if not found
---@param themeName string Name of the theme
---@return Theme? theme Returns theme or nil if not found
function Theme.get(themeName)
return themes[themeName]
end
@@ -487,8 +498,9 @@ end
local ThemeManager = {}
ThemeManager.__index = ThemeManager
---@param config table Configuration options
---@return ThemeManager
---Create a new ThemeManager instance
---@param config table Configuration options {theme: string?, themeComponent: string?, disabled: boolean?, active: boolean?, disableHighlight: boolean?, scaleCorners: number?, scalingAlgorithm: string?}
---@return ThemeManager manager The new ThemeManager instance
function ThemeManager.new(config)
local self = setmetatable({}, ThemeManager)
@@ -506,16 +518,18 @@ function ThemeManager.new(config)
return self
end
---@param element table The parent Element
---Initialize the ThemeManager with a parent element
---@param element Element The parent Element
function ThemeManager:initialize(element)
self._element = element
end
---Update the theme state based on element interaction state
---@param isHovered boolean Whether element is hovered
---@param isPressed boolean Whether element is pressed
---@param isFocused boolean Whether element is focused
---@param isDisabled boolean Whether element is disabled
---@return string The new theme state
---@return string state The new theme state ("normal", "hover", "pressed", "active", "disabled")
function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled)
local newState = "normal"
@@ -533,22 +547,29 @@ function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled)
return newState
end
---@return string The current theme state
---Get the current theme state
---@return string state The current theme state
function ThemeManager:getState()
return self._themeState
end
---@param state string The theme state to set
---Set the theme state explicitly
---@param state string The theme state to set ("normal", "hover", "pressed", "active", "disabled")
function ThemeManager:setState(state)
if type(state) ~= "string" then
return
end
self._themeState = state
end
---@return boolean
---Check if a theme component is set
---@return boolean hasComponent True if a theme component is set
function ThemeManager:hasThemeComponent()
return self.themeComponent ~= nil
end
---@return table?
---Get the theme (either instance-specific or active theme)
---@return Theme? theme The theme instance, or nil if not found
function ThemeManager:getTheme()
if self.theme then
return Theme.get(self.theme)
@@ -556,21 +577,27 @@ function ThemeManager:getTheme()
return Theme.getActive()
end
---@return table?
---Get the base theme component
---@return ThemeComponent? component The theme component, or nil if not found
function ThemeManager:getComponent()
if not self.themeComponent then
return nil
end
local themeToUse = self:getTheme()
if not themeToUse or not themeToUse.components[self.themeComponent] then
if not themeToUse or not themeToUse.components or type(themeToUse.components) ~= "table" then
return nil
end
if not themeToUse.components[self.themeComponent] then
return nil
end
return themeToUse.components[self.themeComponent]
end
---@return table?
---Get the theme component for the current state
---@return ThemeComponent? component The state-specific component, or base component, or nil
function ThemeManager:getStateComponent()
local component = self:getComponent()
if not component then
@@ -578,27 +605,33 @@ function ThemeManager:getStateComponent()
end
local state = self._themeState
if state and state ~= "normal" and component.states and component.states[state] then
if state and state ~= "normal" and component.states and type(component.states) == "table" and component.states[state] then
return component.states[state]
end
return component
end
---@param property string
---@return any?
---Get a style property from the current state component
---@param property string The property name
---@return any? value The property value, or nil if not found
function ThemeManager:getStyle(property)
if type(property) ~= "string" then
return nil
end
local stateComponent = self:getStateComponent()
if not stateComponent then
if not stateComponent or type(stateComponent) ~= "table" then
return nil
end
return stateComponent[property]
end
---@param borderBoxWidth number
---@param borderBoxHeight number
---@return table? {left, top, right, bottom} or nil if no contentPadding
---Get scaled content padding based on border box dimensions
---@param borderBoxWidth number The border box width
---@param borderBoxHeight number The border box height
---@return table? padding Table with {left, top, right, bottom}, or nil if no contentPadding
function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
if not self.themeComponent then
return nil
@@ -639,7 +672,8 @@ function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight)
return nil
end
---@return number?
---Get content auto-sizing multiplier from theme or component
---@return table? multiplier Table with {width: number?, height: number?}, or nil if not defined
function ThemeManager:getContentAutoSizingMultiplier()
if not self.themeComponent then
return nil
@@ -650,7 +684,7 @@ function ThemeManager:getContentAutoSizingMultiplier()
return nil
end
if self.themeComponent then
if self.themeComponent and themeToUse.components and type(themeToUse.components) == "table" then
local component = themeToUse.components[self.themeComponent]
if component and component.contentAutoSizingMultiplier then
return component.contentAutoSizingMultiplier
@@ -666,17 +700,19 @@ function ThemeManager:getContentAutoSizingMultiplier()
return nil
end
---@return string?
---Get the default font family path from the theme
---@return string? fontPath The default font path, or nil if not defined
function ThemeManager:getDefaultFontFamily()
local themeToUse = self:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts["default"] then
if themeToUse and themeToUse.fonts and type(themeToUse.fonts) == "table" and themeToUse.fonts["default"] then
return themeToUse.fonts["default"]
end
return nil
end
---@param themeName string? The theme name
---@param componentName string? The component name
---Set the theme and component for this ThemeManager
---@param themeName string? The theme name to use (nil to use active theme)
---@param componentName string? The component name to use
function ThemeManager:setTheme(themeName, componentName)
self.theme = themeName
self.themeComponent = componentName