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

@@ -8,10 +8,12 @@ local Blur = req("Blur")
local utils = req("utils") local utils = req("utils")
local Units = req("Units") local Units = req("Units")
local Context = req("Context") local Context = req("Context")
---@type StateManager
local StateManager = req("StateManager") local StateManager = req("StateManager")
local ErrorCodes = req("ErrorCodes") local ErrorCodes = req("ErrorCodes")
local ErrorHandler = req("ErrorHandler") local ErrorHandler = req("ErrorHandler")
local ImageRenderer = req("ImageRenderer") local ImageRenderer = req("ImageRenderer")
local ImageScaler = req("ImageScaler")
local NinePatch = req("NinePatch") local NinePatch = req("NinePatch")
local RoundedRect = req("RoundedRect") local RoundedRect = req("RoundedRect")
local ImageCache = req("ImageCache") local ImageCache = req("ImageCache")
@@ -41,6 +43,7 @@ Element.defaultDependencies = {
Units = Units, Units = Units,
Blur = Blur, Blur = Blur,
ImageRenderer = ImageRenderer, ImageRenderer = ImageRenderer,
ImageScaler = ImageScaler,
NinePatch = NinePatch, NinePatch = NinePatch,
RoundedRect = RoundedRect, RoundedRect = RoundedRect,
ImageCache = ImageCache, ImageCache = ImageCache,
@@ -64,14 +67,22 @@ ErrorHandler.init({ ErrorCodes = ErrorCodes })
-- Initialize modules that use ErrorHandler via DI -- Initialize modules that use ErrorHandler via DI
local errorHandlerDeps = { ErrorHandler = ErrorHandler } local errorHandlerDeps = { ErrorHandler = ErrorHandler }
if ImageRenderer.init then ImageRenderer.init(errorHandlerDeps) end if ImageRenderer.init then
ImageRenderer.init(errorHandlerDeps)
end
if ImageScaler then if ImageScaler then
local ImageScaler = req("ImageScaler") local ImageScaler = req("ImageScaler")
if ImageScaler.init then ImageScaler.init(errorHandlerDeps) end if ImageScaler.init then
ImageScaler.init(errorHandlerDeps)
end
end
if NinePatch.init then
NinePatch.init(errorHandlerDeps)
end end
if NinePatch.init then NinePatch.init(errorHandlerDeps) end
local ImageDataReader = req("ImageDataReader") local ImageDataReader = req("ImageDataReader")
if ImageDataReader.init then ImageDataReader.init(errorHandlerDeps) end if ImageDataReader.init then
ImageDataReader.init(errorHandlerDeps)
end
-- Initialize Units module with Context dependency -- Initialize Units module with Context dependency
Units.initialize(Context) Units.initialize(Context)
@@ -111,6 +122,7 @@ flexlove._LICENSE = [[
SOFTWARE. SOFTWARE.
]] ]]
--- Initialize FlexLove with configuration options, set refence scale for autoscaling on window resize, immediate mode, and error logging / error file path
---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition, immediateMode?: boolean, stateRetentionFrames?: number, maxStateEntries?: number, autoFrameManagement?: boolean, errorLogFile?: string, enableErrorLogging?: boolean} ---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition, immediateMode?: boolean, stateRetentionFrames?: number, maxStateEntries?: number, autoFrameManagement?: boolean, errorLogFile?: string, enableErrorLogging?: boolean}
function flexlove.init(config) function flexlove.init(config)
config = config or {} config = config or {}
@@ -186,6 +198,7 @@ function flexlove.resize()
end end
end end
--- Can also be set in init()
---@param mode "immediate"|"retained" ---@param mode "immediate"|"retained"
function flexlove.setMode(mode) function flexlove.setMode(mode)
if mode == "immediate" then if mode == "immediate" then
@@ -211,6 +224,7 @@ function flexlove.getMode()
end end
--- Begin a new immediate mode frame --- Begin a new immediate mode frame
--- You do NOT need to call this directly, it will autodetect, but you can if you need more granular control - must then paired with endFrame()
function flexlove.beginFrame() function flexlove.beginFrame()
if not flexlove._immediateMode then if not flexlove._immediateMode then
return return
@@ -225,6 +239,9 @@ function flexlove.beginFrame()
Context.clearFrameElements() Context.clearFrameElements()
end end
--- End the current immediate mode frame
--- You do NOT need to call this directly unless you call beginFrame() manually - it will autodetect, but you can if you need more granular control
--- MUST BE PAIRED WITH beginFrame()
function flexlove.endFrame() function flexlove.endFrame()
if not flexlove._immediateMode then if not flexlove._immediateMode then
return return
@@ -290,8 +307,8 @@ flexlove._gameCanvas = nil
flexlove._backdropCanvas = nil flexlove._backdropCanvas = nil
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }
---@param gameDrawFunc function|nil ---@param gameDrawFunc function|nil pass component draws that should be affected by a backdrop blur
---@param postDrawFunc function|nil ---@param postDrawFunc function|nil pass component draws that should NOT be affected by a backdrop blur
function flexlove.draw(gameDrawFunc, postDrawFunc) function flexlove.draw(gameDrawFunc, postDrawFunc)
if flexlove._immediateMode and flexlove._autoBeganFrame then if flexlove._immediateMode and flexlove._autoBeganFrame then
flexlove.endFrame() flexlove.endFrame()
@@ -500,6 +517,7 @@ function flexlove.getElementAtPosition(x, y)
return blockingElements[1] return blockingElements[1]
end end
---@param dt number
function flexlove.update(dt) function flexlove.update(dt)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
local topElement = flexlove.getElementAtPosition(mx, my) local topElement = flexlove.getElementAtPosition(mx, my)
@@ -549,6 +567,8 @@ function flexlove.keypressed(key, scancode, isrepeat)
end end
end end
---@param dx number
---@param dy number
function flexlove.wheelmoved(dx, dy) function flexlove.wheelmoved(dx, dy)
local mx, my = love.mouse.getPosition() local mx, my = love.mouse.getPosition()
@@ -673,6 +693,7 @@ function flexlove.wheelmoved(dx, dy)
end end
end end
--- destroys all top-level elements and resets the framework state
function flexlove.destroy() function flexlove.destroy()
for _, win in ipairs(flexlove.topElements) do for _, win in ipairs(flexlove.topElements) do
win:destroy() win:destroy()
@@ -685,6 +706,7 @@ function flexlove.destroy()
flexlove._backdropCanvas = nil flexlove._backdropCanvas = nil
flexlove._canvasDimensions = { width = 0, height = 0 } flexlove._canvasDimensions = { width = 0, height = 0 }
flexlove._focusedElement = nil flexlove._focusedElement = nil
StateManager:reset()
end end
---@param props ElementProps ---@param props ElementProps
@@ -819,8 +841,8 @@ function flexlove.clearAllStates()
StateManager.clearAllStates() StateManager.clearAllStates()
end end
--- Get state statistics (for debugging) --- Get state (immediate mode) statistics (for debugging)
---@return table ---@return { stateCount: number, frameNumber: number, oldestState: number|nil, newestState: number|nil }
function flexlove.getStateStats() function flexlove.getStateStats()
if not flexlove._immediateMode then if not flexlove._immediateMode then
return { stateCount = 0, frameNumber = 0 } return { stateCount = 0, frameNumber = 0 }

View File

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

View File

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

View File

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

View File

@@ -22,28 +22,25 @@ function TestAnimation:testNewWithNilDuration()
end end
function TestAnimation:testNewWithNegativeDuration() function TestAnimation:testNewWithNegativeDuration()
-- Should still create but behave oddly -- Should throw an error for invalid duration
local anim = Animation.new({ luaunit.assertErrorMsgContains("duration must be a positive number", function()
duration = -1, Animation.new({
start = { opacity = 0 }, duration = -1,
final = { opacity = 1 }, start = { opacity = 0 },
}) final = { opacity = 1 },
luaunit.assertNotNil(anim) })
-- Update with positive dt should immediately finish end)
local done = anim:update(1)
luaunit.assertTrue(done)
end end
function TestAnimation:testNewWithZeroDuration() function TestAnimation:testNewWithZeroDuration()
local anim = Animation.new({ -- Should throw an error for invalid duration
duration = 0, luaunit.assertErrorMsgContains("duration must be a positive number", function()
start = { opacity = 0 }, Animation.new({
final = { opacity = 1 }, duration = 0,
}) start = { opacity = 0 },
luaunit.assertNotNil(anim) final = { opacity = 1 },
-- Should be instantly complete })
local done = anim:update(0.001) end)
luaunit.assertTrue(done)
end end
function TestAnimation:testNewWithInvalidEasing() function TestAnimation:testNewWithInvalidEasing()